diff --git a/.dockerignore b/.dockerignore index ab301e50..dccf35c3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,8 @@ .git .github dist -gui/build -gui/frontend/node_modules -gui/frontend/dist +webui/node_modules +webui/dist scripts documentation **/*_test.go diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ec309489..b31c50d4 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -69,21 +69,21 @@ jobs: with: node-version: 24 cache: pnpm - cache-dependency-path: gui/frontend/pnpm-lock.yaml + cache-dependency-path: webui/pnpm-lock.yaml - name: Install frontend dependencies shell: bash - working-directory: gui/frontend + working-directory: webui run: pnpm install --frozen-lockfile - name: Build embedded web assets shell: bash - working-directory: gui/frontend + working-directory: webui run: pnpm run build:bundle - name: Sync embedded web assets shell: pwsh - run: pwsh -NoProfile -File ./scripts/sync-frontend-assets.ps1 + run: pwsh -NoProfile -File ./scripts/sync-webui-assets.ps1 - name: Build CLI shell: bash @@ -168,181 +168,8 @@ jobs: path: dist/packages/upbrr-cli-*-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm != '' && format('v{0}', matrix.goarm) || '' }}${{ matrix.goos == 'windows' && '.zip' || '.tar.gz' }} if-no-files-found: error - build-gui: - name: Build GUI (${{ matrix.platform }}) - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - runner: windows-latest - platform: windows/amd64 - artifact: windows-x64 - - runner: windows-latest - platform: windows/arm64 - artifact: windows-arm64 - - runner: ubuntu-24.04 - platform: linux/amd64 - artifact: linux-x64 - - runner: ubuntu-24.04-arm - platform: linux/arm64 - artifact: linux-arm64 - - runner: macos-15-intel - platform: darwin/amd64 - artifact: macos-x64 - - runner: macos-15 - platform: darwin/arm64 - artifact: macos-arm64 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.release_tag || github.ref }} - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 10.27.0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: gui/frontend/pnpm-lock.yaml - - - name: Install GUI frontend dependencies - shell: bash - working-directory: gui/frontend - run: pnpm install --frozen-lockfile - - - name: Typecheck and build GUI frontend assets - shell: bash - working-directory: gui/frontend - run: | - set -euo pipefail - pnpm run typecheck & - typecheck_pid=$! - pnpm run build:bundle & - build_pid=$! - wait "$typecheck_pid" - wait "$build_pid" - - - name: Sync embedded GUI assets - shell: pwsh - run: pwsh -NoProfile -File ./scripts/sync-frontend-assets.ps1 - - - name: Install Linux GUI dependencies - if: startsWith(matrix.platform, 'linux/') - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - libglib2.0-dev \ - libx11-dev \ - libxkbcommon-dev \ - libxrandr-dev \ - libxinerama-dev \ - libxcursor-dev \ - libxi-dev \ - pkg-config \ - patchelf - - - name: Install Wails CLI - shell: bash - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 - - - name: Build GUI - shell: bash - env: - RELEASE_TAG: ${{ inputs.release_tag }} - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - version="$RELEASE_TAG" - if [[ -z "$version" && "$REF_TYPE" == "tag" ]]; then - version="$REF_NAME" - fi - if [[ -z "$version" ]]; then - version="dev" - fi - build_id="$(git rev-parse --short=12 HEAD)" - ldflags="-s -w -X main.version=${version} -X main.buildIdentifier=${build_id}" - - cd gui - if [[ "${{ matrix.platform }}" == linux/* ]]; then - wails build -platform "${{ matrix.platform }}" -clean -trimpath -tags webkit2_41 -ldflags "$ldflags" - elif [[ "${{ matrix.platform }}" == "windows/arm64" ]]; then - # Wails bindings helper is built for target arch; skip on x64 runners for arm64 target. - wails build -platform "${{ matrix.platform }}" -clean -skipbindings -trimpath -ldflags "$ldflags" - else - wails build -platform "${{ matrix.platform }}" -clean -trimpath -ldflags "$ldflags" - fi - - - name: Package GUI (Windows) - if: startsWith(matrix.platform, 'windows/') - shell: pwsh - env: - RELEASE_TAG: ${{ inputs.release_tag }} - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} - run: | - $version = $env:RELEASE_TAG - if (-not $version -and $env:REF_TYPE -eq "tag") { - $version = $env:REF_NAME - } - if (-not $version) { - $version = "dev" - } - New-Item -ItemType Directory -Force -Path dist/packages | Out-Null - $target = "${{ matrix.artifact }}" - $archive = "dist/packages/upbrr-gui-$version-$target.zip" - if (Test-Path $archive) { - Remove-Item $archive -Force - } - Compress-Archive -Path "gui/build/bin/*" -DestinationPath $archive -Force - - - name: Package GUI (Linux/macOS) - if: ${{ !startsWith(matrix.platform, 'windows/') }} - shell: bash - env: - RELEASE_TAG: ${{ inputs.release_tag }} - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - mkdir -p dist/packages - version="$RELEASE_TAG" - if [[ -z "$version" && "$REF_TYPE" == "tag" ]]; then - version="$REF_NAME" - fi - if [[ -z "$version" ]]; then - version="dev" - fi - target="${{ matrix.artifact }}" - archive="dist/packages/upbrr-gui-${version}-${target}.tar.gz" - tar -C gui/build/bin -czf "$archive" . - - - name: Upload GUI artifact - uses: actions/upload-artifact@v7 - with: - name: gui-${{ matrix.artifact }} - path: dist/packages/upbrr-gui-*-${{ matrix.artifact }}${{ startsWith(matrix.platform, 'windows/') && '.zip' || '.tar.gz' }} - if-no-files-found: error - build-docker-amd64: - name: Build Docker Image (cli+gui linux/amd64) + name: Build Docker Image (CLI+WebUI linux/amd64) runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -424,7 +251,7 @@ jobs: cache-to: type=gha,scope=build-docker-amd64,mode=min build-docker-arm64: - name: Build Docker Image (cli+gui linux/arm64) + name: Build Docker Image (CLI+WebUI linux/arm64) runs-on: ubuntu-24.04-arm timeout-minutes: 45 permissions: @@ -506,7 +333,7 @@ jobs: cache-to: type=gha,scope=build-docker-arm64,mode=min publish-docker-manifest: - name: Publish Docker Manifest (cli+gui) + name: Publish Docker Manifest (CLI+WebUI) runs-on: ubuntu-latest timeout-minutes: 20 needs: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 865a662b..8f3f7eb1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -56,10 +56,10 @@ jobs: with: node-version: 24 cache: pnpm - cache-dependency-path: gui/frontend/pnpm-lock.yaml + cache-dependency-path: webui/pnpm-lock.yaml - name: Install frontend dependencies - working-directory: gui/frontend + working-directory: webui run: pnpm install --frozen-lockfile # Prettier keeps its own per-file cache keyed on Prettier's version and the @@ -70,29 +70,29 @@ jobs: id: prettier-cache uses: actions/cache/restore@v4 with: - path: gui/frontend/node_modules/.cache/prettier - key: prettier-${{ runner.os }}-${{ hashFiles('gui/frontend/package.json', 'gui/frontend/pnpm-lock.yaml', 'gui/frontend/.prettierrc*', 'gui/frontend/prettier.config.*') }} + path: webui/node_modules/.cache/prettier + key: prettier-${{ runner.os }}-${{ hashFiles('webui/package.json', 'webui/pnpm-lock.yaml', 'webui/.prettierrc*', 'webui/prettier.config.*') }} restore-keys: | prettier-${{ runner.os }}- - name: Run frontend lint - working-directory: gui/frontend + working-directory: webui run: pnpm run lint - name: Run frontend dead-code lint - working-directory: gui/frontend + working-directory: webui run: pnpm run lint:dead - name: Run frontend typecheck - working-directory: gui/frontend + working-directory: webui run: pnpm run typecheck - name: Run frontend unit tests - working-directory: gui/frontend + working-directory: webui run: pnpm run test:unit - name: Check frontend formatting - working-directory: gui/frontend + working-directory: webui run: pnpm run format:check # Save only when the primary key wasn't an exact restore-hit so we don't retry @@ -104,5 +104,5 @@ jobs: if: always() && steps.prettier-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: - path: gui/frontend/node_modules/.cache/prettier + path: webui/node_modules/.cache/prettier key: ${{ steps.prettier-cache.outputs.cache-primary-key }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 852eed4a..21371411 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,14 +36,14 @@ jobs: with: node-version: 24 cache: pnpm - cache-dependency-path: gui/frontend/pnpm-lock.yaml + cache-dependency-path: webui/pnpm-lock.yaml - name: Install frontend dependencies - working-directory: gui/frontend + working-directory: webui run: pnpm install --frozen-lockfile - name: Install Playwright browser - working-directory: gui/frontend + working-directory: webui run: pnpm exec playwright install --with-deps chromium - name: Run E2E @@ -54,7 +54,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: playwright-report - path: gui/frontend/playwright-report + path: webui/playwright-report if-no-files-found: ignore - name: Upload Playwright traces @@ -62,5 +62,5 @@ jobs: uses: actions/upload-artifact@v7 with: name: playwright-test-results - path: gui/frontend/test-results + path: webui/test-results if-no-files-found: ignore diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 65314eaa..e8515b2f 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -12,8 +12,8 @@ concurrency: cancel-in-progress: true jobs: - pathpolicy: - name: pathpolicy (${{ matrix.os }}) + policies: + name: repository policies (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 5 strategy: @@ -34,14 +34,20 @@ jobs: go-version-file: go.mod cache: true + - name: Run architecture ownership policy + run: go run ./cmd/architecturepolicy + - name: Run path portability policy run: go run ./cmd/pathpolicy + - name: Run composite literal layout policy + run: go run ./cmd/literalpolicy + golangci: name: golangci-lint runs-on: ubuntu-latest timeout-minutes: 10 - needs: pathpolicy + needs: policies steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f9f1675..153ed41d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,12 +71,6 @@ jobs: path: release-artifacts pattern: cli-* - - name: Download GUI artifacts - uses: actions/download-artifact@v5 - with: - path: release-artifacts - pattern: gui-* - - name: Publish release assets shell: bash env: diff --git a/.github/workflows/vuln.yml b/.github/workflows/vuln.yml index c732b401..6ccf948a 100644 --- a/.github/workflows/vuln.yml +++ b/.github/workflows/vuln.yml @@ -3,7 +3,7 @@ name: vuln on: workflow_dispatch: schedule: - - cron: '17 8 * * 1' + - cron: "17 8 * * 1" permissions: contents: read @@ -37,4 +37,4 @@ jobs: - name: Run OSV dependency scan if: ${{ !cancelled() }} - run: go run github.com/google/osv-scanner/v2/cmd/osv-scanner@${OSV_SCANNER_VERSION} scan source --lockfile go.mod --lockfile gui/frontend/pnpm-lock.yaml + run: go run github.com/google/osv-scanner/v2/cmd/osv-scanner@${OSV_SCANNER_VERSION} scan source --lockfile go.mod --lockfile webui/pnpm-lock.yaml diff --git a/.gitignore b/.gitignore index fd43e7cb..b926385b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,9 @@ -gui/frontend/node_modules/ -gui/frontend/dist/ -gui/frontend/package.json.md5 -gui/frontend/tsconfig.tsbuildinfo -gui/frontend/wailsjs/go/ -gui/build/bin/ -internal/webserver/assets/ -internal/guiapp/assets/* -!internal/guiapp/assets/.keep +webui/node_modules/ +webui/dist/ +webui/package.json.md5 +webui/tsconfig.tsbuildinfo +internal/webserver/assets/* +!internal/webserver/assets/.keep dist/ docs/ tmp/ @@ -19,8 +16,8 @@ tmp/ /scripts/*.py # E2E / Playwright artifacts -gui/frontend/playwright-report/ -gui/frontend/test-results/ +webui/playwright-report/ +webui/test-results/ # Lefthook .lefthook-local.yml @@ -28,8 +25,8 @@ gui/frontend/test-results/ # Lint / formatter caches lint-report.json -gui/frontend/.eslintcache -gui/frontend/node_modules/.cache/prettier/ +webui/.eslintcache +webui/node_modules/.cache/prettier/ # App Configs /config/*.yaml diff --git a/.golangci.yml b/.golangci.yml index a9ab5a33..13cfa888 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,11 +7,22 @@ formatters: enable: - goimports - gofmt + - golines settings: goimports: local-prefixes: - github.com/autobrr/upbrr + golines: + max-len: 160 + tab-len: 4 + shorten-comments: false + reformat-tags: false + chain-split-dots: false + + exclusions: + paths: + - _test\.go$ linters: enable: diff --git a/.vscode/launch.json b/.vscode/launch.json index a8396c6d..d72af7c1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,17 +11,7 @@ "cwd": "${workspaceFolder}" }, { - "name": "CLI: Run with --gui", - "type": "go", - "request": "launch", - "mode": "debug", - "program": "${workspaceFolder}/cmd/upbrr", - "buildFlags": "-tags dev", - "args": ["--gui"], - "cwd": "${workspaceFolder}" - }, - { - "name": "CLI: Serve (Web Mode)", + "name": "CLI: Serve WebUI", "type": "go", "request": "launch", "mode": "debug", @@ -48,27 +38,6 @@ "args": ["--dry-run", "${input:sourcePath}"], "cwd": "${workspaceFolder}" }, - { - "name": "GUI: Run (Wails Entrypoint)", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}/gui", - "buildFlags": "-tags dev", - "cwd": "${workspaceFolder}" - }, - { - "name": "GUI: Debug (Wails Build)", - "type": "go", - "request": "launch", - "mode": "exec", - "program": "${workspaceFolder}/gui/build/bin/upbrr-gui", - "windows": { - "program": "${workspaceFolder}/gui/build/bin/upbrr-gui.exe" - }, - "preLaunchTask": "build-gui", - "cwd": "${workspaceFolder}" - }, { "name": "Test: Current Package", "type": "go", diff --git a/.vscode/settings.json b/.vscode/settings.json index 71e9d5df..cf9c0b4f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,17 +17,17 @@ }, "npm.packageManager": "pnpm", "files.exclude": { - "gui/frontend/node_modules": true, - "gui/frontend/dist": true, - "gui/build/bin": true, - "internal/guiapp/assets": true, + "webui/node_modules": true, + "webui/dist": true, + "webui/playwright-report": true, + "internal/webserver/assets": true, "dist": true }, "search.exclude": { - "gui/frontend/node_modules": true, - "gui/frontend/dist": true, - "gui/build/bin": true, - "internal/guiapp/assets": true, + "webui/node_modules": true, + "webui/dist": true, + "webui/playwright-report": true, + "internal/webserver/assets": true, "dist": true }, @@ -53,7 +53,7 @@ "text": "Hard limit: the subject line (type + scope + subject) must be 115 characters or fewer. Aim for under 72 when the change is simple; use the extra headroom only when it genuinely helps." }, { - "text": "Scope is optional. If used, keep it short (one word, an area like `config`, `ci`, `gui`, `cli`, or a tracker acronym like `bt`, `asc`). Uppercase tracker acronyms are acceptable." + "text": "Scope is optional. If used, keep it short (one word, an area like `config`, `ci`, `webui`, `cli`, or a tracker acronym like `bt`, `asc`). Uppercase tracker acronyms are acceptable." }, { "text": "Body (optional, separated from subject by a blank line): explain *why* the change was made and any non-obvious context. Wrap body lines at ~115 characters. Do not restate what the diff already shows." diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 29ae5217..277c090a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,29 +9,20 @@ "windows": { "args": ["build", "-o", "dist/upbrr.exe", "./cmd/upbrr"] }, - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "group": "build", "problemMatcher": ["$go"] }, - { - "label": "build-gui", - "type": "shell", - "command": "go", - "args": [ - "run", - "github.com/wailsapp/wails/v2/cmd/wails@v2.10.1", - "build" - ], - "options": { "cwd": "${workspaceFolder}/gui" }, - "group": "build", - "problemMatcher": [] - }, { "label": "frontend-install", "type": "shell", "command": "pnpm", "args": ["install", "--frozen-lockfile"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "problemMatcher": [] }, { @@ -39,7 +30,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "build"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "dependsOn": ["frontend-install"], "problemMatcher": [] }, @@ -48,16 +41,20 @@ "type": "shell", "command": "pnpm", "args": ["run", "dev"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "isBackground": true, "problemMatcher": [] }, { "label": "format-go", "type": "shell", - "command": "golangci-lint", - "args": ["fmt", "./..."], - "options": { "cwd": "${workspaceFolder}" }, + "command": "make", + "args": ["fmt-go"], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": [] }, { @@ -65,7 +62,9 @@ "type": "shell", "command": "golangci-lint", "args": ["run", "--timeout=5m"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "group": "test", "problemMatcher": ["$go"] }, @@ -74,7 +73,9 @@ "type": "shell", "command": "golangci-lint", "args": ["run", "--fix", "--timeout=5m"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": ["$go"] }, { @@ -82,7 +83,51 @@ "type": "shell", "command": "go", "args": ["run", "./cmd/logpolicy"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "test", + "problemMatcher": { + "owner": "go", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + } + }, + { + "label": "pathpolicy", + "type": "shell", + "command": "go", + "args": ["run", "./cmd/pathpolicy"], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "test", + "problemMatcher": { + "owner": "go", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + } + }, + { + "label": "literalpolicy", + "type": "shell", + "command": "go", + "args": ["run", "./cmd/literalpolicy"], + "options": { + "cwd": "${workspaceFolder}" + }, "group": "test", "problemMatcher": { "owner": "go", @@ -101,7 +146,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "format"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "problemMatcher": [] }, { @@ -109,7 +156,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "format:check"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "group": "test", "problemMatcher": [] }, @@ -118,7 +167,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "lint"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "group": "test", "problemMatcher": ["$eslint-stylish"] }, @@ -127,7 +178,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "lint:fix"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "problemMatcher": ["$eslint-stylish"] }, { @@ -135,7 +188,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "typecheck"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "group": "test", "problemMatcher": ["$tsc"] }, @@ -144,7 +199,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "lint:dead"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "group": "test", "problemMatcher": [] }, @@ -153,7 +210,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "lint:style"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "group": "test", "problemMatcher": [] }, @@ -162,7 +221,9 @@ "type": "shell", "command": "pnpm", "args": ["run", "lint:style:fix"], - "options": { "cwd": "${workspaceFolder}/gui/frontend" }, + "options": { + "cwd": "${workspaceFolder}/webui" + }, "problemMatcher": [] }, { @@ -170,8 +231,13 @@ "type": "shell", "command": "go", "args": ["test", "-v", "-timeout", "20m", "./..."], - "options": { "cwd": "${workspaceFolder}" }, - "group": { "kind": "test", "isDefault": true }, + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "test", + "isDefault": true + }, "problemMatcher": { "owner": "go-test", "fileLocation": ["relative", "${workspaceFolder}"], @@ -189,7 +255,9 @@ "type": "shell", "command": "go", "args": ["run", "./cmd/upbrr", "serve"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "isBackground": true, "problemMatcher": { "owner": "go", @@ -211,9 +279,14 @@ }, { "label": "build-all", - "dependsOn": ["build-cli", "build-gui"], "group": "build", - "problemMatcher": [] + "problemMatcher": [], + "type": "shell", + "command": "make", + "args": ["build"], + "options": { + "cwd": "${workspaceFolder}" + } }, { "label": "lint-all", @@ -224,7 +297,9 @@ "lint-style-frontend", "typecheck-frontend", "format-check-frontend", - "logpolicy" + "logpolicy", + "pathpolicy", + "literalpolicy" ], "group": "test", "problemMatcher": [] @@ -260,7 +335,9 @@ "type": "shell", "command": "lefthook", "args": ["install"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": [] }, { @@ -268,7 +345,9 @@ "type": "shell", "command": "lefthook", "args": ["run", "pre-commit", "--all-files"], - "options": { "cwd": "${workspaceFolder}" }, + "options": { + "cwd": "${workspaceFolder}" + }, "group": "test", "problemMatcher": [] } diff --git a/ADDING_TRACKERS.md b/ADDING_TRACKERS.md new file mode 100644 index 00000000..69063a36 --- /dev/null +++ b/ADDING_TRACKERS.md @@ -0,0 +1,681 @@ +# Adding tracker support + +This guide covers two supported extension paths: + +- a Unit3D tracker under `internal/trackers/impl/unit3d/sites/` +- a standalone, non-Unit3D tracker under `internal/trackers/impl/standalone/` + +AvistaZ-family (`azfamily`) trackers are intentionally out of scope. + +## Ownership rules + +- `internal/trackers/impl/registry.go` is the only complete supported-tracker manifest. +- A Unit3D or standalone profile owns the tracker's default endpoint and typed + policies. +- Every tracker declares one semantic shared upload-content mode. Unit3D inherits + `description` from its family definition; standalone profiles choose explicitly. +- `internal/config/defaults/example.yaml` owns the ordered config/settings surface. It must not + contain tracker `url` fields. +- A tracker is configured when at least one activation credential in its config stanza is + non-empty. Authentication readiness is a separate concern. +- Tracker-specific behavior stays in the tracker package. Do not add tracker-name dispatch to + generic metadata, auth, image-hosting, torrent-client, config, or frontend code. +- The backend tracker catalog drives the frontend. Adding a tracker that uses existing config + fields normally requires no tracker-specific frontend edit. +- Every registered tracker must expose a `dupe.Factory`, even when duplicate search can only + return a typed not-run result. +- Upload and dry-run must share payload preparation. Dry-run must not submit the upload or repeat + workflow-level discovery and duplicate checking. + +## Fast file checklist + +A standard Unit3D addition normally changes: + +1. `internal/trackers/impl/unit3d/sites//profile.go` +2. `internal/trackers/impl/unit3d/sites//rules.go`, when site rules exist +3. `internal/trackers/impl/registry.go` +4. `internal/config/defaults/example.yaml` +5. `internal/trackers/rules_test.go`, when rules exist +6. a site-local `profile_test.go`, when mappings or callbacks differ from Unit3D defaults + +Static banned groups, custom naming/descriptions, image-host rules, and other policies add +site-local files only. + +A standalone addition normally changes: + +1. a new `internal/trackers/impl/standalone/` package containing `profile.go`, upload, + duplicate-search, and focused test code +2. `internal/trackers/impl/registry.go` +3. `internal/config/defaults/example.yaml` +4. `internal/trackers/rules_test.go`, when rules exist +5. shared config/frontend field contracts only when no existing `config.TrackerConfig` field fits + +## Choose the shared upload-content mode + +`trackers.UploadContentMode` tells generic preparation which tracker-scoped content object the +adapter consumes. Choose by protocol behavior, not tracker identity: + +| Mode | Use when | Adapter input | Failure scope | +| --- | --- | --- | --- | +| `none` | The adapter builds its payload without shared descriptions or selected images | `PreparationInput.Assets` is `nil` | Shared content cannot block the tracker | +| `screenshots` | The adapter consumes selected screenshots/menu images but no shared description | Ready screenshot assets, which may be empty | A failed screenshot object blocks only that tracker | +| `description` | The adapter consumes the full shared description plus its image assets | Ready aggregate description assets, which may contain empty text | A failed description object or required image substep blocks only that tracker | + +Ready-empty content is valid and differs from failed content. Generic coordinators never infer a +failure from empty text or zero selected images. + +All Unit3D and AvistaZ-family definitions currently declare `description` once at family level. +Standalone profiles must set `UploadContentMode` explicitly. Current protocol examples are BTN/NBL +for `none`, ANT/RTF for `screenshots`, and most other standalone trackers for `description`. +Changing a standalone tracker's workflow later should require only its profile and tracker-local +adapter implementation; do not add tracker-name branches to core or tracker orchestration. + +## Add a Unit3D tracker + +### 1. Confirm that shared Unit3D behavior fits + +The shared implementation already supplies: + +- bearer API-key auth +- `/api/torrents/upload` uploads +- `/api/torrents/filter` duplicate searches +- standard Unit3D descriptions and multipart payloads +- generic Unit3D metadata lookup +- standard category, type, and resolution IDs +- standard torrent identity matching from the profile base URL +- required MediaInfo encode-setting validation + +If the site changes only rules, IDs, naming, description formatting, payload fields, or typed +policies, keep it as a Unit3D profile. If it replaces the protocol substantially, implement it as +a standalone tracker instead of filling shared Unit3D code with site-name branches. + +### 2. Create the site package + +Create: + +```text +internal/trackers/impl/unit3d/sites/example/ + profile.go + rules.go # when the site has release rules + banned_groups.go # when the site has a static list + profile_test.go # when profile behavior differs from defaults +``` + +Additional site-local files such as `name.go`, `description.go`, or `audio_policy.go` are useful +when `profile.go` would otherwise become a collection of unrelated behavior. + +A minimal profile is: + +```go +package example + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns EXAMPLE's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "EXAMPLE", + BaseURL: "https://tracker.example.invalid", + Rules: Rules(), + } +} +``` + +`Name` is the stable uppercase identifier. `BaseURL` is authoritative and must be a valid HTTPS +origin. Do not add an endpoint override to config. + +### 3. Handle category, type, and resolution IDs + +Shared Unit3D upload mapping is: + +| Dimension | Normalized value | Default ID | +| ---------- | -------------------- | ---------- | +| Category | `MOVIE` | `1` | +| Category | `TV` | `2` | +| Type | `DISC` | `1` | +| Type | `REMUX` | `2` | +| Type | `ENCODE` or `DVDRIP` | `3` | +| Type | `WEBDL` | `4` | +| Type | `WEBRIP` | `5` | +| Type | `HDTV` | `6` | +| Resolution | `4320p` | `1` | +| Resolution | `2160p` | `2` | +| Resolution | `1080p` or `1440p` | `3` | +| Resolution | `1080i` | `4` | +| Resolution | `720p` | `5` | +| Resolution | `576p` / `576i` | `6` / `7` | +| Resolution | `480p` / `480i` | `8` / `9` | +| Resolution | unknown or `8640p` | `10` | + +Use site-local `SiteProfile` callbacks when the site differs: + +```go +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "EXAMPLE", + BaseURL: "https://tracker.example.invalid", + Rules: Rules(), + Site: unit3d.SiteProfile{ + ResolveCategoryID: categoryID, + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + }, + } +} + +func categoryID(meta api.UploadSubject) string { + switch { + case strings.EqualFold(unit3d.Category(meta), "TV") && meta.TVPack: + return "9" + case strings.EqualFold(unit3d.Category(meta), "TV"): + return "2" + default: + return unit3d.DefaultCategoryID(meta) + } +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "10", + "REMUX": "20", + "ENCODE": "30", + "WEBDL": "40", + }[unit3d.InferType(meta)] +} + +func resolutionID(meta api.UploadSubject) string { + return map[string]string{ + "2160p": "1", + "1080p": "2", + "720p": "3", + }[unit3d.Resolution(meta)] +} +``` + +Available upload callbacks are: + +| Callback | Use | +| ------------------------ | ---------------------------------------------------------------------- | +| `BuildName` | Replace shared release-name formatting | +| `BuildDescription` | Replace shared Unit3D description rendering | +| `ResolveKeywords` | Filter or remap the `keywords` field | +| `ResolveTypeID` | Map prepared release facts to a site type ID | +| `ResolveResolutionID` | Map prepared release facts to a site resolution ID | +| `ResolveCategoryID` | Map canonical category and site facts to a site category ID | +| `ApplyAdditionalPayload` | Add site-only upload fields after the common payload is built | +| `FinalizeDescription` | Transform the completed shared description without replacing its build | + +An empty or `"0"` custom type ID is treated as unsupported and blocks payload preparation. Test +every accepted type. Category and resolution callbacks should also return deliberate values for +all supported inputs; do not let accidental empty IDs reach the API. + +#### Duplicate-search mappings can differ independently + +`SiteProfile.ResolveCategoryID`, `ResolveTypeID`, and `ResolveResolutionID` currently affect upload +and dry-run payloads only. Unit3D duplicate search builds a separate filter query. + +If the new tracker uses nonstandard duplicate-search IDs, omits filters, changes the filter path, +or searches pending torrents, treat that as a Unit3D family-contract extension: + +1. Add a typed duplicate-search callback/policy to the Unit3D profile contract. +2. Pass the composed profile into the Unit3D duplicate adapter. +3. Apply the callback in `dupe_params.go` or the Unit3D data client without adding a new + tracker-name conditional. +4. Define the mapping in the new site's `profile.go` or a site-local `dupe.go`. +5. Test the exact query parameters and response normalization. + +Do not assume that custom upload IDs are also correct for duplicate search. Do not copy existing +legacy site-name exceptions as the pattern for new trackers. + +### 4. Add tracker rules + +Declare standard rules through `trackers.RuleSet`. Unit3D automatically enables +`RequireValidMISetting`, so the site does not need to repeat it. + +An adult rule plus Aither-style language rule looks like: + +```go +package example + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns EXAMPLE's release eligibility requirements. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + AdultMessage: "Adult content is not allowed at EXAMPLE.", + Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonDisc: true, + }, + } +} +``` + +Other reusable `RuleSet` fields cover: + +- category restrictions: movie-only, TV-only, or movie-unless-TV-pack +- disc-only, minimum resolution, HEVC, DVD rip, external subtitle, hardcoded subtitle, and scene + NFO constraints +- blocked groups and group/type exceptions +- single-file folders +- structured language requirements + +Use `RuleSet.Check` when site-local policy cannot be expressed by the common fields. It returns +one or more keyed `api.RuleFailure` values plus an operational error: + +```go +Check: func(ctx context.Context, subject api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if blocked(subject) { + return []api.RuleFailure{trackers.NewRuleFailure( + "stable_rule_key", + "Operator-facing reason.", + api.RuleDispositionWaivable, + )}, nil + } + return nil, nil +}, +``` + +Choose the disposition deliberately: `advisory` never blocks, `waivable` requires exact user +authorization, and `strict` cannot be bypassed. Every predicate whose result depends on resolution, +including a required but missing resolution, must be `strict`. Keep rule keys stable and unique +within the tracker. Cancellation, malformed runtime state, and external failures return `error`; +they are not rule failures. + +Site-local checks should consume `api.RuleSubject` and Unit3D helpers such as `RuleType`, +`RuleResolution`, `RuleGroup`, `RuleGenres`, `RuleKeywords`, `Anime`, `Animation`, and +`AdultContent`. Provider metadata must belong to the exact prepared source; do not introduce +fallbacks to stale parser or provider state. + +Add behavior cases to the combined `internal/trackers/rules_test.go`. Use +`EvaluateRulesWithRegistry` so tests include Unit3D-wide requirements. Add focused site tests only +for site-local mapping or complex custom-check behavior. + +### 5. Add banned groups + +For a static list, keep the data beside the site: + +```go +package example + +// BannedGroups returns EXAMPLE's static release-group blacklist. +func BannedGroups() []string { + return []string{"GROUP-A", "GROUP-B"} +} +``` + +Attach it in `Profile()`: + +```go +BannedGroups: BannedGroups(), +``` + +For a dynamic Unit3D blacklist, declare the endpoint in the profile: + +```go +BannedPolicy: &trackers.BannedGroupPolicy{ + EndpointPath: "/api/blacklists/releasegroups", + RequireAPIKey: true, +}, +``` + +Use `DefaultEndpoint` only for a tracker-owned absolute endpoint that cannot derive from +`BaseURL`. Use `TRaSHGuideURL` for an external TRaSH-compatible source. Set `RawAPIKeyFallback` only +when the remote API requires that known alternate authorization form. Parsing or auth behavior not +represented by `BannedGroupPolicy` needs a typed policy extension, not a tracker-name branch in +`internal/trackers/banned.go`. + +### 6. Add other site-owned policies only when required + +`unit3d.Profile` can also declare: + +| Profile field | Purpose | +| ------------------ | ------------------------------------------------------------ | +| `AudioPolicy` | Multi-language/bloat policy beyond release eligibility rules | +| `DupePolicy` | Candidate-comparison semantics after duplicate search | +| `UploadArtifact` | Torrent source/announce personalization | +| `ImageHost` | Accepted, private, or conditional image hosts | +| `TorrentIdentity` | Extra announce/comment aliases and torrent reuse preferences | +| `ClaimPolicy` | Generic claim orchestration requirements | +| `DescriptionGroup` | Site-specific saved description override group | + +Keep construction in `profile.go`; move substantial policy logic or data into a clearly named +site-local file. If a capability does not exist, extend the typed profile/definition contract once +and keep the site decision in the site package. + +### 7. Register the profile + +In `internal/trackers/impl/registry.go`: + +1. Import the site package. +2. Add `example.Profile()` to `unit3DDefinitions()`. + +Do not add package `init()` registration. Add the tracker to `SetPriorityOrder` only when it needs +an intentional curated priority; otherwise remaining Unit3D trackers are appended automatically. + +### 8. Add the config/settings stanza + +Add the tracker in the intended display order in `internal/config/defaults/example.yaml`: + +```yaml +EXAMPLE: + link_dir_name: "" + api_key: "" + anon: false + modq: false +``` + +Rules: + +- Include only fields the tracker consumes. +- Never add `url`. +- Standard Unit3D activation uses an empty `api_key` default. +- Activation credentials must default to an empty string. +- Recognized activation keys are `api_key`, `ApiUser`, `ApiKey`, `username`, `password`, + `passkey`, `announce_url`, and `my_announce_url`. +- `link_dir_name`, `favicon_url`, `image_host`, and `torrent_client` are not activation + credentials. +- Stanza field order becomes frontend field order. + +When every field already exists in `config.TrackerConfig`, no tracker-specific frontend table is +needed. If the tracker requires a genuinely new config field, also update: + +- `config.TrackerConfig` and its YAML/JSON tags in `internal/config/config.go` +- activation-key handling in `internal/config/tracker_catalog.go`, if the field configures the + tracker +- secret encryption/decryption coverage in `internal/config/secrets.go`, if sensitive +- generic frontend presentation metadata in `webui/src/settings/trackerFields.ts` +- config persistence, catalog, and frontend settings tests + +Prefer an existing semantically correct field over adding an alias for the same credential. + +### 9. Unit3D tests + +At minimum, cover: + +- registry/config parity +- profile endpoint and family +- rule allow/deny cases in `internal/trackers/rules_test.go` +- every nonstandard category/type/resolution mapping +- additional payload fields and description/name transforms +- static/dynamic banned-group behavior when present +- duplicate-search query mapping when it differs from standard Unit3D +- dry-run/live payload parity for site-specific fields + +Existing contract tests also enforce endpoint locality, absence of generic tracker dispatch, and a +duplicate factory for every registered tracker. + +## Add a standalone non-Unit3D tracker + +### 1. Create a tracker-owned package + +Use a package such as: + +```text +internal/trackers/impl/standalone/example/ + profile.go + upload.go + dupe.go + rules.go # optional + banned_groups.go # optional + auth.go # optional + data.go # optional dynamic lookup + claims.go # optional dynamic claim checks + profile_test.go + upload_test.go + dupe_test.go +``` + +Keep all endpoint, payload, auth, parser, rule, and policy behavior in this package. Shared protocol +helpers belong in a neutral package only when more than one implementation genuinely shares the +same contract. + +### 2. Compose the tracker profile + +Every standalone package owns one `profile.go`. It composes identity, the default endpoint, +preparation callbacks, duplicate-search factory, auth descriptor, and static typed policy: + +```go +// Profile returns EXAMPLE's standalone tracker composition. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "EXAMPLE", + BaseURL: "https://tracker.example.invalid", + UploadContentMode: trackers.UploadContentModeDescription, + DescriptionGroup: "example", + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + BannedGroups: bannedGroups(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: "EXAMPLE", + }, + } +} + +// New returns a fresh EXAMPLE definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } +``` + +`standalone.Definition` normalizes and defensively copies the profile, reports +`trackers.FamilyStandalone`, and exposes declared capabilities. Required fields are name, base URL, +upload-content mode, upload preparer, and duplicate factory. `PrepareDescription` is required only +for `description`; omit it for `none` and `screenshots`. + +Keep rules and banned groups in tracker-local files and pass their package functions into the +profile. Fold one-method static policy files into `profile.go`. Keep complex auth/session, parser, +description, data, and claim behavior in focused tracker-local files. + +### 3. Prepare one immutable upload operation + +For `description`, implement two preparation callbacks: + +- `prepareDescription`: build or pass through the description and return its group. +- `prepareUpload`: build canonical payload/artifact state once and return a + `trackers.PreparedOperation` containing its sanitized preview and a submit closure over the + captured state. + +For `none` or `screenshots`, implement only `prepareUpload`. A screenshot-mode adapter reads +`req.Assets.Screenshots`, `req.Assets.MenuImages`, and `req.Assets.Slots`; it must tolerate a ready +empty object when its protocol allows zero images. + +```go +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "EXAMPLE", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "example", + Description: state.description, + Endpoint: state.endpoint, + Payload: state.previewFields(), // secrets redacted + Files: state.previewFiles(), + }) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, state) + }, state.release), nil +} +``` + +Description-preview calls only the description preparer. Dry-run and upload-review prepare a +preview but cannot submit. Upload submits the captured state once. A submit closure may acquire a +short-lived CSRF token, but must not rebuild canonical fields, rerun image uploads, or reread +mutable prepared-release inputs. `Release` owns temporary resources and is exact-once. + +### 4. Keep category/type handling local + +Map the standalone protocol's categories, types, resolutions, flags, and payload fields inside the +tracker package. Consume finalized prepared contracts: + +- `api.UploadSubject` for upload/dry-run +- `api.DuplicateSubject` for duplicate search +- `api.RuleSubject` for rules + +Do not add the mapping to `internal/metadata/media_details.go` or another generic package. Upload +and duplicate-search mappings are separate API contracts; test both when they differ. + +### 5. Implement duplicate search + +Every tracker must implement `dupe.Factory`: + +```go +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + return &duplicateSearcher{ + cfg: deps.TrackerConfig(), + http: deps.HTTPClient(), + logger: deps.Logger(), + } +} +``` + +The adapter's `Search` must return one structural result: + +- `dupe.Resolved` for a completed search, including zero matches +- `dupe.NotRun` with a stable code and safe message for missing credentials/metadata, + unsupported content, manual checking, or unavailable implementation +- `dupe.Failed` with a stable failure code, safe message, and diagnostic cause for attempted work + that failed + +Use only the dependency snapshot supplied to the factory. Do not read unrelated global config. +Bound response bodies, sanitize remote diagnostics, and normalize entries into `api.DupeEntry`. + +### 6. Add rules and banned groups + +Standalone profiles use the same typed capabilities as Unit3D: + +```go +func rules() *trackers.RuleSet { ... } +func bannedGroups() []string { ... } + +// profile.go +Rules: rules(), +BannedGroups: bannedGroups(), +BannedGroupPolicy: &trackers.BannedGroupPolicy{...}, +``` + +Keep implementations in `rules.go` and `banned_groups.go`. Add combined rule behavior to +`internal/trackers/rules_test.go`; add tracker-package tests for protocol-specific parsing or +mapping. + +### 7. Add optional capabilities + +Declare static capabilities directly in `standalone.Profile`: + +| Profile field | Typical supporting file | Purpose | +| ------------------------- | ----------------------- | -------------------------------------------- | +| `AuthCapability` | `auth.go` | Declares API-key, passkey, cookie, login/2FA | +| `AuthResolver` | `auth.go` | Validates or refreshes remote auth | +| `AuthPolicy` | `profile.go` | Auth coordinator semantics | +| `AuthStateManager` | `auth.go` | Cleans tracker-owned persisted auth state | +| `Rules` | `rules.go` | Release eligibility | +| `MetadataPolicy` | `profile.go` | Required canonical/provider metadata | +| `ArtifactPolicy` | `profile.go` | Torrent size and piece-size limits | +| `UploadArtifactPolicy` | `profile.go` | Source/announce personalization | +| `BannedGroups` / policy | `banned_groups.go` | Static or dynamic blacklists | +| `DupePolicy` | `dupe.go` | Candidate comparison semantics | +| `AudioPolicy` | `profile.go` | Multi-language/bloat constraints | +| `ImageHostPolicy` | `profile.go` | Allowed/private image hosts | +| `TorrentIdentityPolicy` | `profile.go` | Announce/comment identity and reuse behavior | +| `LocalizedMetadataLocale` | `profile.go` | Locale-specific tracker rendering | +| `DescriptionGroup` | `profile.go` | Saved description override group | +| `DataPolicy` | `profile.go` | Lookup cooldown/defer behavior | +| `ClaimPolicy` | `profile.go` | Active-claim orchestration | + +Implement only capabilities the tracker needs. If new behavior cannot be expressed by an existing +typed capability, extend the shared profile/registry contract; do not +teach generic coordinators the tracker name. + +Rare dynamic interfaces stay on a small local wrapper embedding `*standalone.Definition`. Use this +only for `NewDataLookup`, `DataLookupConfigured`, or `NewClaimChecker`; do not create an empty local +definition type for static capabilities. + +For auth: + +- API-key/passkey-only trackers should expose an `AuthCapability` with the corresponding + `Requires...` flag. +- Cookie/login trackers should expose `AuthCapability` and `AuthSessionResolver`. +- Use `AuthPolicy` only for coordinator semantics that cannot be inferred from the public + capability. +- Store and clean any tracker-specific auth material through an `AuthStateManager`. + +### 8. Register and configure the tracker + +In `internal/trackers/impl/registry.go`: + +1. Import `internal/trackers/impl/standalone/example`. +2. Add `example.New()` to `standaloneDefinitions()`. + +Add an ordered stanza to `internal/config/defaults/example.yaml`. It must contain at least one +empty activation credential such as `api_key`, `announce_url`, `username`, `password`, or +`passkey`, depending on the tracker's real setup requirements. Do not add `url`. + +The registry/config parity test fails when either side is missing. Existing config fields render +through the generic frontend catalog. Follow the additional config-field steps from the Unit3D +section when a new field is unavoidable. + +### 9. Standalone tests + +At minimum, cover: + +- definition name, endpoint, family, and registered capabilities +- config/catalog parity +- upload payload construction and response parsing +- dry-run/live payload parity and absence of dry-run network submission +- duplicate request construction, result normalization, not-run states, and failures +- category/type/resolution mapping +- rule and banned-group behavior +- auth status/login/session behavior when implemented +- bounded response handling and sanitized diagnostics +- torrent artifact and image-host behavior when declared + +## Validation checklist + +Start narrow, then run the shared tracker checks because registry and config changes affect all +trackers: + +```powershell +go test -race -v -timeout 20m ./internal/trackers/impl/unit3d/... ./internal/trackers/impl ./internal/trackers ./internal/config +``` + +For a standalone tracker, replace the Unit3D package with its package: + +```powershell +go test -race -v -timeout 20m ./internal/trackers/impl/standalone/example ./internal/trackers/impl ./internal/trackers ./internal/config +``` + +Then run the repo-required checks appropriate to the change: + +```powershell +make fmt-go +make gofix-check-changed +make lint +make logpolicy +make backend +git diff --check +``` + +Run `make test-go` when shared Unit3D behavior, tracker orchestration, auth, config persistence, or +other broad contracts changed. When a new config field changes the WebUI surface, also run: + +```powershell +pnpm --dir webui run lint +pnpm --dir webui run lint:dead +pnpm --dir webui run typecheck +pnpm --dir webui run test:unit +pnpm --dir webui run format:check +``` + +Do not commit generated frontend assets, local binaries, Playwright output, or populated +`internal/webserver/assets`. diff --git a/AGENTS.md b/AGENTS.md index 16bd0a7c..428b89ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Always-loaded repo rules for AI coding agents. Keep this file short; nearest sco ## Source Of Truth -- Tool config wins: `Makefile`, `lefthook.yml`, `.golangci.yml`, `gui/frontend/package.json`, `.github/workflows/*`. +- Tool config wins: `Makefile`, `lefthook.yml`, `.golangci.yml`, `webui/package.json`, `.github/workflows/*`. - Contributor setup and command detail: `CONTRIBUTING.md`. - Guidance disagrees with tools? Follow tools and update stale prose. @@ -13,8 +13,8 @@ Always-loaded repo rules for AI coding agents. Keep this file short; nearest sco - Backend, Go, path/log policy, trackers/config/domain rules, runtime architecture, lint/check policy: `internal/AGENTS.md`. - CLI flags/prompts/unattended behavior: `cmd/upbrr/AGENTS.md`. - Shared API/runtime contracts: `pkg/api/AGENTS.md`. -- Frontend, React, CSS, TypeScript, browser checks: `gui/frontend/AGENTS.md`. -- Playwright E2E harness, fake services, reports, manual workflow: `gui/frontend/e2e/AGENTS.md`. +- Frontend, React, CSS, TypeScript, browser checks: `webui/AGENTS.md`. +- Playwright E2E harness, fake services, reports, manual workflow: `webui/e2e/AGENTS.md`. Read the scoped file before editing that area. For simple grep/read-only questions, avoid loading extra instructions unless needed. @@ -25,32 +25,36 @@ make help # supported targets make backend # fast CLI build sanity make test-go # full Go race tests make test-frontend # frontend lint/dead-code/type/unit/format -make lint # path policy + full Go lint +make lint # architecture/path/literal policies + full Go lint make precommit # strong local validation before commit; no Go tests make prepush # Lefthook pre-push wrapper git diff --check # whitespace/conflict markers ``` -Start narrow; expand checks for shared behavior, release, GUI/web parity, or safety-sensitive changes. Do not substitute hook wrappers for the area-specific checks below. +Start narrow; expand checks for shared behavior, release, WebUI/API parity, or safety-sensitive changes. Do not substitute hook wrappers for the area-specific checks below. ## Area Checks - Backend/Go: focused `go test -race -v -timeout 20m ` for touched packages; `make test-go` when shared core behavior or broad regressions are plausible; `make lint`; `make logpolicy` for logging/internal changes; `make pathpolicy` for path-handling changes. - CLI: `go test -race -v -timeout 20m ./cmd/upbrr ./internal/core ./pkg/api`; add touched service/tracker packages; run `make backend` for command/build sanity. -- GUI/web/API parity: `go test -race -v -timeout 20m ./internal/guiapp ./internal/webserver ./internal/guishared ./pkg/api`; add frontend `typecheck`/unit checks when request/response shapes or runtime bridge code changes. -- Frontend: `pnpm --dir gui/frontend run lint`, `lint:dead`, `typecheck`, `test:unit`, `format:check`; add `lint:style` for CSS and `build` for bundle/runtime issues. -- E2E/browser: read `gui/frontend/e2e/AGENTS.md`; use embedded web checks, not Vite-only checks, for parity-sensitive UI/runtime changes. +- WebUI/API: `go test -race -v -timeout 20m ./internal/webserver/... ./pkg/api`; add frontend `typecheck`/unit checks when request/response shapes or browser client code changes. +- Frontend: `pnpm --dir webui run lint`, `lint:dead`, `typecheck`, `test:unit`, `format:check`; add `lint:style` for CSS and `build` for bundle/runtime issues. +- E2E/browser: read `webui/e2e/AGENTS.md`; use embedded web checks, not Vite-only checks, for runtime-sensitive UI changes. -Before commit, also run `git diff --check`, changed-package `make gofix-check-changed`, and the relevant hook commands if desired. If Go files, generated dirs, or scratch paths can affect package discovery, run `make lint` before commit. +Before commit, also run `git diff --check`, changed-package `make gofix-check-changed`, and the relevant hook commands if desired. `make fmt-go` applies the 160-column formatter and expands keyed composite literals with three or more elements. If Go files, generated dirs, or scratch paths can affect package discovery, run `make lint` before commit. ## Repo Map -- CLI `cmd/upbrr`; core `internal/core`; services `internal/services`; trackers `internal/trackers`; config `internal/config`. -- Wails `gui`, `internal/guiapp`; embedded web/API `internal/webserver`; API contracts `pkg/api`; frontend `gui/frontend`. +- CLI `cmd/upbrr`; workflow orchestration `internal/core`; config `internal/config`; remaining domain services `internal/services`. +- Canonical prepared generations/display `internal/preparedrelease`; external identity `internal/externalidentity`; source resources `internal/sourcelayout`; torrent-client discovery `internal/clientdiscovery`. +- Tracker contracts/orchestration `internal/trackers`; implementation families `internal/trackers/impl/{unit3d,azfamily,standalone}`; standalone trackers `internal/trackers/impl/standalone/`; Unit3D sites `internal/trackers/impl/unit3d/sites/`. +- Tracker auth/dupe/data coordinators `internal/trackers/{auth,dupe,data}`; generic BBCode/description/image hosting `internal/{bbcode,description,imagehosting}`. +- Paths `internal/pathing` with `layout` and checker-only `policy`; torrent clients `internal/torrentclient`; metainfo `internal/torrent/metainfo`; release policy `internal/releasepolicy`. +- WebUI server/API and retained jobs `internal/webserver`; API contracts `pkg/api`; frontend release ownership `webui/src/releaseSession`; shared job coordination `webui/src/jobRegistry`. ## Logging Levels -- Keep log levels purposeful across CLI, GUI, embedded web, frontend, tests, and tooling. +- Keep log levels purposeful across CLI, WebUI, tests, and tooling. - Add logs for operator-visible progress and decision points, not just final errors. Good logs answer what operation started, what external/local check ran, what decision was made, and how many items were affected. - `INFO` should provide concise, relevant progress or outcome details for end users during uploads and other top-level workflows. - Warnings should cover failed or blocked outcomes that require attention. @@ -61,9 +65,9 @@ Before commit, also run `git diff --check`, changed-package `make gofix-check-ch ## Non-Negotiables - Keep changes narrow; fix root cause; do not revert user changes. -- Preserve CLI, Wails GUI, and embedded web parity. +- Preserve CLI and WebUI behavior where they share workflows. - Preserve CLI `--unattended` / `--unattended_confirm` (`--uac`) safety: `--unattended` must not prompt; `--unattended_confirm` may ask required confirmation/manual inputs. No hidden prompts/confirms or ambiguous fallthrough. - Never log credentials/tokens/API keys/cookies/secret payloads; use repo redaction/logging policy. - Shareable examples, diagnostics, docs, and test fixtures must not use real release names, real movie/show titles, or real provider IDs. Use synthetic placeholders such as `Example Release 2026`, `Example.Release.2026.1080p-GRP`, and `tt1234567`. Prefer generic group tags such as `GRP` when the group is incidental; real group names are allowed when relevant to behavior. Production domain lists such as tracker banned groups may keep real values when those values are required behavior. -- Do not commit generated/local output: `dist/`, `gui/frontend/dist/`, `gui/build/bin/`, populated `internal/guiapp/assets`, Playwright reports/results, repo-local `tmp/`. +- Do not commit generated/local output: `dist/`, `webui/dist/`, populated `internal/webserver/assets`, Playwright reports/results, repo-local `tmp/`. - `.github/workflows/*.yml` files are active; `.yml22` files are disabled templates. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc50ca96..fa113de7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,15 +21,11 @@ Install the following on your machine: - [Git](https://git-scm.com/) - [Go](https://golang.org/dl/) — see [go.mod](./go.mod) for the required version -- [Node.js](https://nodejs.org) (20 LTS or newer) -- [pnpm](https://pnpm.io/installation) (10 or newer — version is pinned in `gui/frontend/package.json` via `packageManager`) +- [Node.js](https://nodejs.org) (`^20.19.0`, `^22.13.0`, or `>=24`) +- [pnpm](https://pnpm.io/installation) (10 or newer — version is pinned in `webui/package.json` via `packageManager`) - [GNU Make](https://www.gnu.org/software/make/) — top-level shortcuts for builds, checks, formatting, and hooks - [golangci-lint](https://golangci-lint.run/) — used by hooks and CI - [Lefthook](https://github.com/evilmartians/lefthook) — git hooks runner (see [Git hooks](#git-hooks-lefthook)) -- [Wails CLI](https://wails.io/) `v2.12.0` for desktop builds - - Build scripts invoke `go run github.com/wailsapp/wails/v2/cmd/wails@v2.12.0` - -On Linux, Wails builds also need GTK/WebKit development packages. The full list is in [`.github/workflows/build-binaries.yml`](./.github/workflows/build-binaries.yml) — key packages are `build-essential`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libglib2.0-dev`, and `pkg-config`. Builds using WebKitGTK 4.1 must pass the Go build tag `webkit2_41`. ### Supported platforms @@ -50,7 +46,7 @@ Notes: - **Fork and clone:** [Fork the upbrr repository](https://github.com/autobrr/upbrr/fork) and clone it to start working on your changes. - **Branching:** Create a descriptively named branch. - Example: `git checkout -b fix/bt-dupe-check` or `git checkout -b feat/playlist-selection` -- **Coding:** Keep changes narrow and match the surrounding style. For Go, follow the rules in [`AGENTS.md`](./AGENTS.md) and let `golangci-lint` drive. For frontend work, also see [`gui/frontend/AGENTS.md`](./gui/frontend/AGENTS.md) and let the Lefthook Prettier + ESLint hooks do the work. +- **Coding:** Keep changes narrow and match the surrounding style. For Go, follow the rules in [`AGENTS.md`](./AGENTS.md) and let `golangci-lint` drive. For frontend work, also see [`webui/AGENTS.md`](./webui/AGENTS.md) and let the Lefthook Prettier + ESLint hooks do the work. - **Commit messages:** We enforce [Conventional Commits](https://www.conventionalcommits.org/) via a repo-local validator. See [Commit message format](#commit-message-format) below. - No need to force-push or rebase — we squash on merge. - **Pull requests:** Submit a PR with a clear description. Mark it _Draft_ if still in progress. Reference related issues. @@ -66,15 +62,15 @@ go install github.com/evilmartians/lefthook/v2@v2.1.0 lefthook install # Frontend / full-stack contributors: -# `pnpm install` in gui/frontend/ brings in the Prettier + ESLint deps the hooks call. -cd gui/frontend && pnpm install && cd ../.. +# `pnpm install` in webui/ brings in the Prettier + ESLint deps the hooks call. +pnpm --dir webui install lefthook install ``` What runs when Git invokes hooks: -- `pre-commit` — on **staged files only**: `prettier --write` (gui/frontend), `eslint` (gui/frontend/src), `golangci-lint fmt` (Go), `go run ./cmd/logpolicy` (when `internal/**` Go files change), and `go run ./cmd/pathpolicy` (when Go files change). Formatters auto-re-stage their fixes. -- `pre-push` — full-project TypeScript typecheck and `make lint`, which runs the path-portability checker before golangci-lint. CI mirrors the Go test/pathpolicy OS matrix and frontend checks for pull requests. +- `pre-commit` — on **staged files only**: `prettier --write` (webui), `eslint` (webui/src), `golangci-lint fmt` (Go), the composite-literal layout policy, `go run ./cmd/logpolicy` (when `internal/**` Go files change), and `go run ./cmd/pathpolicy` (when Go files change). Formatters auto-re-stage their fixes. +- `pre-push` — full-project TypeScript typecheck and `make lint`, which runs the architecture ownership, path-portability, and composite-literal layout checkers before golangci-lint. CI mirrors these Go policy checks and frontend checks for pull requests. - `commit-msg` — `go run ./cmd/commitmsgcheck` enforces [Conventional Commits](https://www.conventionalcommits.org/) without requiring Node.js or `pnpm install`. Makefile shortcuts: @@ -103,7 +99,7 @@ Use [Conventional Commits](https://www.conventionalcommits.org/): ``` - **Allowed types:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. -- **Scope** is optional (e.g. `config`, `ci`, `gui`, `cli`, or a tracker acronym like `bt`, `asc`). +- **Scope** is optional (e.g. `config`, `ci`, `webui`, `cli`, or a tracker acronym like `bt`, `asc`). - **Subject** is imperative, lower-case, no trailing period, **115 characters max** (derived from history + 30% headroom). - **Breaking change:** append `!` after type/scope (`feat!: drop Go 1.19`) or add a `BREAKING CHANGE:` footer. @@ -128,19 +124,19 @@ git clone https://github.com//upbrr && cd upbrr ### Frontend ```sh -pnpm --dir gui/frontend install --frozen-lockfile +pnpm --dir webui install --frozen-lockfile make dev-frontend # Vite dev server -pnpm --dir gui/frontend run lint -pnpm --dir gui/frontend run lint:dead -pnpm --dir gui/frontend run typecheck -pnpm --dir gui/frontend run test:unit -pnpm --dir gui/frontend run format:check +pnpm --dir webui run lint +pnpm --dir webui run lint:dead +pnpm --dir webui run typecheck +pnpm --dir webui run test:unit +pnpm --dir webui run format:check # CSS changes: -pnpm --dir gui/frontend run lint:style +pnpm --dir webui run lint:style # Bundle/runtime changes: -pnpm --dir gui/frontend run build +pnpm --dir webui run build ``` For embedded web visual checks, test the embedded build rather than the Vite-only server. Rebuild the frontend, sync it into embedded assets, rebuild the CLI, then run the auth-disabled embedded server on the main port: @@ -166,21 +162,17 @@ go run ./cmd/upbrr serve # Run the embedded web server without web auth for local frontend development: go run ./cmd/upbrr serve --dev-no-auth -# Launch the Wails desktop GUI: -go run ./cmd/upbrr --gui -# or the dedicated GUI entrypoint: -go run ./gui ``` ### Build -Full build (CLI + Wails GUI + embedded frontend): +Full build (CLI with embedded WebUI): ```sh make build ``` -`make build` runs the platform build script, installs frontend deps, builds the React frontend, syncs it into `internal/guiapp/assets`, then builds the CLI into `dist/` and the Wails GUI into `gui/build/bin/`. +`make build` runs the platform build script, installs frontend deps, builds the React frontend, syncs it into `internal/webserver/assets`, then builds `dist/upbrr[.exe]`. Individual pieces: @@ -188,7 +180,6 @@ Individual pieces: make backend # CLI only make frontend # Typecheck + frontend bundle make frontend-bundle # Vite bundle only -make gui # Wails GUI with current embedded assets ``` ## Tests and checks @@ -203,18 +194,18 @@ go test -race -v -timeout 20m go test -race -v -timeout 20m ./cmd/upbrr ./internal/core ./pkg/api make backend -# Wails/web/API parity: -go test -race -v -timeout 20m ./internal/guiapp ./internal/webserver ./internal/guishared ./pkg/api +# WebUI/API contracts: +go test -race -v -timeout 20m ./internal/webserver/... ./pkg/api # Frontend TS/TSX: -pnpm --dir gui/frontend run lint -pnpm --dir gui/frontend run lint:dead -pnpm --dir gui/frontend run typecheck -pnpm --dir gui/frontend run test:unit -pnpm --dir gui/frontend run format:check +pnpm --dir webui run lint +pnpm --dir webui run lint:dead +pnpm --dir webui run typecheck +pnpm --dir webui run test:unit +pnpm --dir webui run format:check # CSS: -pnpm --dir gui/frontend run lint:style +pnpm --dir webui run lint:style # Broad Go regression, lint, and policy sweeps: make test-go @@ -229,19 +220,26 @@ Useful focused checks: go test -race -v -timeout 20m make gofix-check-changed make gofix-changed -pnpm --dir gui/frontend run lint:style +pnpm --dir webui run lint:style ``` Alternatively, `make precommit` and `make prepush` run the configured Lefthook checks. -`make precommit` also runs stronger local validation: whitespace/conflict-marker checks, changed-package Go fix drift, full Go lint/path policy, log policy, and frontend lint/dead-code/type/unit/format checks. Hook wrappers do not replace focused Go tests, CSS Stylelint, embedded parity checks, or E2E checks when those areas changed. +`make precommit` also runs stronger local validation: whitespace/conflict-marker checks, changed-package Go fix drift, full Go lint plus architecture/path/literal policies, log policy, and frontend lint/dead-code/type/unit/format checks. Hook wrappers do not replace focused Go tests, CSS Stylelint, embedded parity checks, or E2E checks when those areas changed. ## Project conventions -- The frontend build output is embedded into the Go app from `internal/guiapp/assets`. -- The GUI, CLI, and embedded web server share the same core services and config model under `internal/`. -- Tracker-specific code lives primarily under `internal/trackers/impl`. Shared tracker behaviour goes in `internal/trackers`, not in the impls. +- The frontend build output is embedded into the Go app from `internal/webserver/assets`. +- The CLI and embedded WebUI share the same core services and config model under `internal/`. +- Canonical prepared generations and display projections live under `internal/preparedrelease`; external identity, source-resource resolution, and torrent-client discovery live under `internal/externalidentity`, `internal/sourcelayout`, and `internal/clientdiscovery`. +- WebUI runtime activation and retained background jobs live under `internal/webserver`; frontend release state and shared Job coordination live under `webui/src/releaseSession` and `webui/src/jobRegistry`. +- Tracker implementations follow registry families: Unit3D protocol behavior and sites live under `internal/trackers/impl/unit3d`, AvistaZ-family behavior under `internal/trackers/impl/azfamily`, and every other tracker in its own `internal/trackers/impl/standalone/` package. +- The family-grouped supported manifest lives only in `internal/trackers/impl/registry.go`. Site profiles/definitions own default endpoints and policy; `internal/config/defaults/example.yaml` owns ordered credential/config fields and contains no tracker URL. A standard Unit3D addition should need only its site profile/rules, one manifest entry, one example-config stanza, and combined rule tests. +- Standalone packages compose identity, preparation callbacks, duplicate factories, auth descriptors, and static policy in `profile.go`. Shared `standalone.Definition` supplies registry capabilities; only dynamic data/claim factories need a small local wrapper. Upload preparation captures one immutable operation used by preview and submission. +- Shared tracker contracts and registry-driven orchestration live under `internal/trackers`; generic auth, dupe, and tracker-data coordinators are in its `auth`, `dupe`, and `data` subpackages. +- Generic release/path/torrent-client infrastructure lives under `internal/releasepolicy`, `internal/pathing`, and `internal/torrentclient`. Torrent metainfo helpers live under `internal/torrent/metainfo`. +- Generic BBCode, description, and image-hosting infrastructure lives under `internal/bbcode`, `internal/description`, and `internal/imagehosting`; tracker-specific policy stays with its tracker implementation. - `pkg/api` holds request/response types shared across surfaces. -- The repo currently includes generated and built assets in a few locations; review changes carefully and avoid committing build output by accident. +- Generated frontend bundles, populated embedded assets, Playwright output, and local binaries are ignored local artifacts. Do not commit them. ### Shareable fixtures and examples @@ -281,22 +279,22 @@ upbrr targets Windows, Linux, and macOS. Do not assume POSIX path behavior in Go - Use `path` only for slash-delimited data formats, such as torrent-internal file names, URLs, or API payloads defined to use `/`. - At boundaries between torrent/API paths and local filesystem paths, normalize deliberately: validate slash paths first, then convert with `filepath.FromSlash`. - Security/path traversal checks must reject both POSIX and Windows absolute or escaping forms on every OS: leading `/`, leading `\`, drive-letter paths, UNC paths, and `..` segments. -- Use `internal/pathutil.IsWithinRoot` and `internal/pathutil.SamePath` for local root containment and path equality. Do not add ad-hoc `filepath.Rel` plus string-prefix guards; `pathpolicy` rejects those helper names outside `internal/pathutil`. +- Use `internal/pathing.IsWithinRoot` and `internal/pathing.SamePath` for local root containment and path equality. Do not add ad-hoc `filepath.Rel` plus string-prefix guards; `pathpolicy` rejects those helper names outside `internal/pathing`. - Tests should not assert raw `"/foo/"` substrings against local filesystem paths. Use `filepath.ToSlash(path)` for cross-platform assertions, or build expected paths with `filepath.Join`. - Tests should not pass hardcoded OS-rooted literals such as `C:\...`, `\\server\share`, or `/tmp/...` into `filepath` calls. Use `t.TempDir` or existing path variables. - Do not build local filesystem paths with string concatenation, `fmt.Sprintf`, or `strings.Join(..., "/")`. Use `filepath.Join`. - Use `path.Base`, `path.Ext`, and related `path` APIs for URL/API paths. Use `filepath.Base`, `filepath.Ext`, and related `filepath` APIs for local paths. Legit stdlib `path` imports need import-local `//nolint:depguard // `. -`make pathpolicy` runs the repo-local AST checker for hardcoded OS-rooted literals in `filepath` calls, string-built local paths, wrong `path`/`filepath` package use, slash-data filesystem calls, slash assertions without `filepath.ToSlash`, and ad-hoc local path guard helpers outside `internal/pathutil`. Rare intentional checker exceptions need `//pathpolicy:allow ` on the same or previous line. `make lint`, pre-commit, and pre-push run it automatically. +`make pathpolicy` runs the repo-local AST checker for hardcoded OS-rooted literals in `filepath` calls, string-built local paths, wrong `path`/`filepath` package use, slash-data filesystem calls, slash assertions without `filepath.ToSlash`, and ad-hoc local path guard helpers outside `internal/pathing`. Rare intentional checker exceptions need `//pathpolicy:allow ` on the same or previous line. `make lint`, pre-commit, and pre-push run it automatically. ## AI agent instructions This project uses [AGENTS.md](https://agents.md/) — an open standard for guiding AI coding agents. The root [`AGENTS.md`](./AGENTS.md) file contains always-loaded repo rules and routes agents to scoped references: -- [`gui/frontend/AGENTS.md`](./gui/frontend/AGENTS.md) for frontend, React, CSS, TypeScript, and browser checks. +- [`webui/AGENTS.md`](./webui/AGENTS.md) for frontend, React, CSS, TypeScript, and browser checks. - [`internal/AGENTS.md`](./internal/AGENTS.md) for Go, path/log policy, trackers/config/domain rules, runtime architecture, lint/check policy, and generated/scratch path risks. - [`cmd/upbrr/AGENTS.md`](./cmd/upbrr/AGENTS.md) for CLI flags, prompts, and unattended behavior. - [`pkg/api/AGENTS.md`](./pkg/api/AGENTS.md) for cross-entrypoint API/runtime contracts. -- [`gui/frontend/e2e/AGENTS.md`](./gui/frontend/e2e/AGENTS.md) for Playwright E2E harness rules and commands. +- [`webui/e2e/AGENTS.md`](./webui/e2e/AGENTS.md) for Playwright E2E harness rules and commands. Most modern AI coding tools support `AGENTS.md` natively or via simple configuration. `CLAUDE.md` files are symlinks to the same guidance for Claude Code. diff --git a/Dockerfile b/Dockerfile index 7b1a71e2..6ad36c9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,12 @@ ARG GO_VERSION=1.26.5 FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend -WORKDIR /src/gui/frontend +WORKDIR /src/webui -COPY gui/frontend/package.json gui/frontend/pnpm-lock.yaml ./ +COPY webui/package.json webui/pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile -COPY gui/frontend/ ./ +COPY webui/ ./ RUN pnpm run build:bundle FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS cli-builder @@ -27,8 +27,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY . . # Embed the built web UI so `upbrr serve` can serve it. Mirrors -# scripts/sync-frontend-assets.ps1 used by the binary release workflow. -COPY --from=frontend /src/gui/frontend/dist/ ./internal/guiapp/assets/ +# scripts/sync-webui-assets.ps1 used by the binary release workflow. +COPY --from=frontend /src/webui/dist/ ./internal/webserver/assets/ ARG TARGETOS ARG TARGETARCH diff --git a/Makefile b/Makefile index 60459556..859121b2 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,32 @@ -.PHONY: help build backend frontend frontend-bundle gui dev dev-frontend test test-go test-frontend e2e e2e-build e2e-web e2e-cli e2e-wails lint lint-json logpolicy pathpolicy precommit prepush fmt fmt-go fmt-frontend gofix gofix-check gofix-changed gofix-check-changed commitmsg-check clean +.PHONY: help build backend frontend frontend-bundle dev dev-frontend test test-go test-frontend e2e e2e-build e2e-web e2e-cli lint lint-json logpolicy pathpolicy literalpolicy architecturepolicy literalpolicy-fix precommit prepush fmt fmt-go fmt-frontend gofix gofix-check gofix-changed gofix-check-changed commitmsg-check clean ifeq ($(OS),Windows_NT) EXE := .exe FULL_BUILD := powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/build.ps1 MKDIR_DIST := powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path dist | Out-Null" -RM_DIST := powershell -NoProfile -Command "Remove-Item -Recurse -Force dist, gui/frontend/dist, gui/build/bin -ErrorAction SilentlyContinue" -WAILS_PLATFORM := windows/amd64 +RM_DIST := powershell -NoProfile -Command "Remove-Item -Recurse -Force dist, webui/dist -ErrorAction SilentlyContinue" BLANK := echo. else EXE := FULL_BUILD := ./scripts/build.sh MKDIR_DIST := mkdir -p dist -RM_DIST := rm -rf dist gui/frontend/dist gui/build/bin -WAILS_PLATFORM := +RM_DIST := rm -rf dist webui/dist BLANK := echo endif CLI_OUT := dist/upbrr$(EXE) E2E_CLI_OUT := dist/upbrr-e2e$(EXE) -WAILS_CLI := go run github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 GO_TEST_FLAGS := -race -v -timeout 20m GOLANGCI_FLAGS := --timeout=5m -GO_CHANGED_FILES := $(shell git diff --name-only --diff-filter=ACMR HEAD -- '*.go') -GO_CHANGED_PKGS := $(addprefix ./,$(sort $(patsubst %/,%,$(dir $(GO_CHANGED_FILES))))) - help: @echo Build - @echo make build Full build: frontend, embedded assets, CLI, Wails GUI + @echo make build Full build: WebUI, embedded assets, CLI @echo make backend Build CLI binary only @echo make frontend Typecheck and build frontend bundle @echo make frontend-bundle Build frontend bundle only - @echo make gui Build Wails GUI with current embedded assets @$(BLANK) @echo Development - @echo make dev Start Wails dev mode + @echo make dev Start WebUI server without auth on loopback @echo make dev-frontend Start Vite dev server only @$(BLANK) @echo Testing @@ -43,10 +36,10 @@ help: @echo make e2e Run all Playwright E2E projects @echo make e2e-web Run embedded web E2E projects @echo make e2e-cli Run CLI full-upload E2E project - @echo make e2e-wails Run Wails/backend parity E2E project @$(BLANK) @echo Linting - @echo make lint Run path policy and full Go lint + @echo make lint Run architecture/path/literal policies and full Go lint + @echo make architecturepolicy Run architecture ownership policy check @echo make lint-json Write Go lint JSON to lint-report.json @echo make logpolicy Run logging policy check @echo make pathpolicy Run path portability policy check @@ -72,23 +65,16 @@ backend: go build -o $(CLI_OUT) ./cmd/upbrr frontend: - pnpm --dir gui/frontend run build + pnpm --dir webui run build frontend-bundle: - pnpm --dir gui/frontend run build:bundle - -gui: -ifeq ($(WAILS_PLATFORM),) - cd gui && $(WAILS_CLI) build -else - cd gui && $(WAILS_CLI) build -platform $(WAILS_PLATFORM) -endif + pnpm --dir webui run build:bundle dev: - cd gui && $(WAILS_CLI) dev + go run ./cmd/upbrr serve --dev-no-auth dev-frontend: - pnpm --dir gui/frontend run dev + pnpm --dir webui run dev test: test-go test-frontend @@ -96,37 +82,33 @@ test-go: go test $(GO_TEST_FLAGS) ./... test-frontend: - pnpm --dir gui/frontend run lint - pnpm --dir gui/frontend run lint:dead - pnpm --dir gui/frontend run typecheck - pnpm --dir gui/frontend run test:unit - pnpm --dir gui/frontend run format:check + pnpm --dir webui run lint + pnpm --dir webui run lint:dead + pnpm --dir webui run typecheck + pnpm --dir webui run test:unit + pnpm --dir webui run format:check e2e: e2e-build - pnpm --dir gui/frontend run test:e2e:full + pnpm --dir webui run test:e2e:full e2e-build: - pnpm --dir gui/frontend install --frozen-lockfile - pnpm --dir gui/frontend run build + pnpm --dir webui install --frozen-lockfile + pnpm --dir webui run build ifeq ($(OS),Windows_NT) - pwsh -NoProfile -File ./scripts/sync-frontend-assets.ps1 + pwsh -NoProfile -File ./scripts/sync-webui-assets.ps1 else - sh ./scripts/sync-frontend-assets.sh + sh ./scripts/sync-webui-assets.sh endif $(MKDIR_DIST) go build -tags e2e -o $(E2E_CLI_OUT) ./cmd/upbrr e2e-web: e2e-build - pnpm --dir gui/frontend run test:e2e:web + pnpm --dir webui run test:e2e:web e2e-cli: e2e-build - pnpm --dir gui/frontend exec playwright test --project=cli-full-upload + pnpm --dir webui exec playwright test --project=cli-full-upload -e2e-wails: - pnpm --dir gui/frontend install --frozen-lockfile - pnpm --dir gui/frontend exec playwright test --project=wails-basic - -lint: pathpolicy +lint: architecturepolicy pathpolicy literalpolicy golangci-lint run $(GOLANGCI_FLAGS) ./... lint-json: @@ -138,6 +120,16 @@ logpolicy: pathpolicy: go run ./cmd/pathpolicy +literalpolicy: + go run ./cmd/literalpolicy + +architecturepolicy: + go run ./cmd/architecturepolicy + +literalpolicy-fix: + go run ./cmd/literalpolicy -fix + golangci-lint fmt + precommit: lefthook run pre-commit git diff --check @@ -152,10 +144,11 @@ prepush: fmt: fmt-go fmt-frontend fmt-go: + go run ./cmd/literalpolicy -fix golangci-lint fmt fmt-frontend: - pnpm --dir gui/frontend run format + pnpm --dir webui run format gofix: go fix -omitzero=false ./... @@ -164,18 +157,10 @@ gofix-check: go fix -diff -omitzero=false ./... gofix-changed: -ifeq ($(strip $(GO_CHANGED_PKGS)),) - @echo No changed Go files -else - go fix -omitzero=false $(GO_CHANGED_PKGS) -endif + go run ./cmd/gofixchanged -write gofix-check-changed: -ifeq ($(strip $(GO_CHANGED_PKGS)),) - @echo No changed Go files -else - go fix -diff -omitzero=false $(GO_CHANGED_PKGS) -endif + go run ./cmd/gofixchanged commitmsg-check: go run ./cmd/commitmsgcheck $(MSG) diff --git a/README.md b/README.md index 8c22844e..c4262b83 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Have these ready: brew install ffmpeg ``` - The CLI can find ffmpeg when your shell PATH includes Homebrew. If you start the GUI from Finder, macOS may not pass your shell PATH to the app, so the GUI may not see Homebrew's ffmpeg. Start the GUI from Terminal, or make ffmpeg available in the environment used to launch the app. + The CLI and WebUI server can find ffmpeg when their process environment includes Homebrew's bin directory. - Automatic DVD menu screenshots need an FFmpeg build whose `dvdvideo` demuxer exposes `menu`, `menu_lu`, `menu_vts`, `pgc`, and `pg`. A version number alone does not prove this capability. upbrr checks the selected FFmpeg at runtime; see [FFmpeg's dvdvideo documentation](https://ffmpeg.org/ffmpeg-formats.html#dvdvideo). - A TMDB API key is optional, but required for most trackers in upbrr. @@ -82,7 +82,7 @@ The importer accepts: Read all warnings printed during import. Unknown legacy keys, unsupported tracker fields, or unsupported image-host settings may be skipped or adjusted. -You can also import config files from the Settings page in the GUI or web UI. +You can also import config files from the WebUI Settings page. ### Web UI Browse Access @@ -120,12 +120,6 @@ Restart upbrr after copying cookies, then verify tracker login/session status be ## Running upbrr -Desktop GUI: - -```powershell -.\upbrr-gui.exe -``` - Web UI: ```powershell @@ -230,11 +224,11 @@ Useful CLI checks: ```powershell .\upbrr.exe --site-check --trackers BLU,OE "D:\releases\Some.Release" -.\upbrr.exe --dry-run --trackers PTP,HDB "D:\releases\Some.Release" +.\upbrr.exe --debug --trackers PTP,HDB "D:\releases\Example.Release.2026" .\upbrr.exe --queue "D:\upload-queue" --limit-queue 5 ``` -NOTE: with cli `--debug` works as expected. Additionally, the printed feedback (even with debug) can be adjusted with `--log-level`. See `upbrr.exe --help` +CLI `--debug` builds and prints the same non-submitting tracker payload preview as the WebUI dry run. It never submits to trackers. `--log-level debug` changes log verbosity only and does not select debug/dry-run behavior. See `upbrr.exe --help`. ### Automatic DVD Menu Screenshots @@ -246,7 +240,7 @@ CLI example: .\upbrr.exe --get-dvd-menus "D:\releases\Example.Release.2026.DVD-GRP" ``` -In the GUI or web UI, fetch DVD metadata, then open **Disc Menus** and start automatic capture. Manual Disc Menu image import remains available for DVD, BDMV, and HDDVD sources. `screenshot_handling.max_menu_items` controls the maximum stored automatic menu images; the default is `6` and the supported range is `1` to `32`. +In the WebUI, fetch DVD metadata, then open **Disc Menus** and start automatic capture. Manual Disc Menu image import remains available for DVD, BDMV, and HDDVD sources. `screenshot_handling.max_menu_items` controls the maximum stored automatic menu images; the default is `6` and the supported range is `1` to `32`. The FFmpeg capability check must find the `dvdvideo` demuxer and all required menu-coordinate options. If it does not, capture stops with an explicit capability error instead of sampling menu VOB files. Application Details shows the embedded Go DVD engine version, FFmpeg version, and menu-capability status without exposing local executable paths. diff --git a/cmd/architecturepolicy/main.go b/cmd/architecturepolicy/main.go new file mode 100644 index 00000000..6ebc71de --- /dev/null +++ b/cmd/architecturepolicy/main.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package main + +import ( + "fmt" + "io" + "os" + + "github.com/autobrr/upbrr/internal/architecturepolicy" +) + +func main() { + os.Exit(run(os.Stdout, os.Stderr, os.Getwd, architecturepolicy.CheckRepository)) +} + +func run( + stdout io.Writer, + stderr io.Writer, + getwd func() (string, error), + check func(string) ([]architecturepolicy.Violation, error), +) int { + root, err := getwd() + if err != nil { + fmt.Fprintf(stderr, "architecturepolicy: determine working directory: %v\n", err) + return 1 + } + violations, err := check(root) + if err != nil { + fmt.Fprintf(stderr, "architecturepolicy: %v\n", err) + return 1 + } + if len(violations) == 0 { + fmt.Fprintln(stdout, "architecturepolicy: no issues found") + return 0 + } + for _, violation := range violations { + fmt.Fprintf(stderr, "%s:%d:%d: %s\n", violation.File, violation.Line, violation.Column, violation.Message) + } + return 1 +} diff --git a/cmd/gofixchanged/main.go b/cmd/gofixchanged/main.go new file mode 100644 index 00000000..a3d983ee --- /dev/null +++ b/cmd/gofixchanged/main.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +var errFixDrift = errors.New("changed Go files need go fix") + +func main() { + write := flag.Bool("write", false, "apply fixes to changed Go files") + flag.Parse() + + if err := run(context.Background(), *write, os.Stdout); err != nil { + if !errors.Is(err, errFixDrift) { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(1) + } +} + +func run(ctx context.Context, write bool, stdout io.Writer) error { + root, err := gitOutput(ctx, "rev-parse", "--show-toplevel") + if err != nil { + return err + } + root = strings.TrimSpace(root) + + changed, err := changedGoFiles(ctx, root) + if err != nil { + return err + } + if len(changed) == 0 { + _, err = fmt.Fprintln(stdout, "No changed Go files") + if err != nil { + return fmt.Errorf("print no-changes result: %w", err) + } + return nil + } + + diff, fixErr := goFixDiff(ctx, root, changedPackages(root, changed)) + filtered, err := filterDiff(diff, root, changed) + if err != nil { + return err + } + if len(filtered) == 0 { + if fixErr != nil && !errors.Is(fixErr, errFixDrift) { + return fixErr + } + return nil + } + if fixErr != nil && !errors.Is(fixErr, errFixDrift) { + return fixErr + } + + if write { + return applyPatch(ctx, root, filtered) + } + if _, err := stdout.Write(filtered); err != nil { + return fmt.Errorf("print go fix diff: %w", err) + } + return errFixDrift +} + +func gitOutput(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return string(output), nil +} + +func changedGoFiles(ctx context.Context, root string) ([]string, error) { + tracked, err := gitFileList(ctx, root, "diff", "--name-only", "-z", "--diff-filter=ACMR", "HEAD", "--", "*.go") + if err != nil { + return nil, fmt.Errorf("list modified Go files: %w", err) + } + untracked, err := gitFileList(ctx, root, "ls-files", "--others", "--exclude-standard", "-z", "--", "*.go") + if err != nil { + return nil, fmt.Errorf("list untracked Go files: %w", err) + } + return append(tracked, untracked...), nil +} + +func gitFileList(ctx context.Context, root string, args ...string) ([]string, error) { + cmdArgs := append([]string{"-C", root}, args...) + cmd := exec.CommandContext(ctx, "git", cmdArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + parts := bytes.Split(output, []byte{0}) + files := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) > 0 { + files = append(files, filepath.ToSlash(string(part))) + } + } + return files, nil +} + +func changedPackages(root string, files []string) []string { + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + dir := filepath.Dir(filepath.FromSlash(file)) + seen[filepath.Join(root, dir)] = struct{}{} + } + + packages := make([]string, 0, len(seen)) + for pkg := range seen { + packages = append(packages, pkg) + } + sort.Strings(packages) + return packages +} + +func goFixDiff(ctx context.Context, root string, packages []string) ([]byte, error) { + args := append([]string{"fix", "-diff", "-omitzero=false"}, packages...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = root + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if stderr.Len() > 0 { + return stdout.Bytes(), fmt.Errorf("go fix changed packages: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if err != nil { + if stdout.Len() > 0 { + return stdout.Bytes(), errFixDrift + } + return nil, fmt.Errorf("go fix changed packages: %w", err) + } + return stdout.Bytes(), nil +} + +func filterDiff(diff []byte, root string, changed []string) ([]byte, error) { + wanted := make(map[string]struct{}, len(changed)) + for _, file := range changed { + wanted[normalizedPath(file)] = struct{}{} + } + + lines := splitLines(diff) + var filtered bytes.Buffer + for i := 0; i < len(lines); { + if !bytes.HasPrefix(lines[i], []byte("--- ")) { + i++ + continue + } + if i+1 >= len(lines) || !bytes.HasPrefix(lines[i+1], []byte("+++ ")) { + return nil, errors.New("parse go fix diff: missing new-file header") + } + + end := i + 2 + for end < len(lines) && !bytes.HasPrefix(lines[end], []byte("--- ")) { + end++ + } + rel, err := diffRelativePath(root, string(lines[i+1]), "+++ ", " (new)") + if err != nil { + return nil, err + } + if _, ok := wanted[normalizedPath(rel)]; ok { + fmt.Fprintf(&filtered, "--- a/%s\n+++ b/%s\n", filepath.ToSlash(rel), filepath.ToSlash(rel)) + for _, line := range lines[i+2 : end] { + filtered.Write(line) + } + } + i = end + } + return filtered.Bytes(), nil +} + +func splitLines(input []byte) [][]byte { + if len(input) == 0 { + return nil + } + lines := bytes.SplitAfter(input, []byte("\n")) + if len(lines[len(lines)-1]) == 0 { + return lines[:len(lines)-1] + } + return lines +} + +func diffRelativePath(root, header, prefix, suffix string) (string, error) { + value := strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(header, prefix)), suffix) + rel, err := filepath.Rel(root, value) + if err != nil { + return "", fmt.Errorf("resolve go fix diff path %q: %w", value, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("go fix diff path is outside repository: %s", value) + } + return filepath.ToSlash(rel), nil +} + +func normalizedPath(value string) string { + value = filepath.ToSlash(filepath.Clean(filepath.FromSlash(value))) + if runtime.GOOS == "windows" { + return strings.ToLower(value) + } + return value +} + +func applyPatch(ctx context.Context, root string, patch []byte) error { + cmd := exec.CommandContext(ctx, "git", "-C", root, "apply", "--whitespace=nowarn", "-") + cmd.Stdin = bytes.NewReader(patch) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("apply go fix changes: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/cmd/gofixchanged/main_test.go b/cmd/gofixchanged/main_test.go new file mode 100644 index 00000000..cc5ada53 --- /dev/null +++ b/cmd/gofixchanged/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestChangedPackages(t *testing.T) { + t.Parallel() + + root := t.TempDir() + got := changedPackages(root, []string{ + "internal/metadata/thexem/changed.go", + "internal/metadata/thexem/other.go", + "internal/services/trackericon/changed.go", + }) + want := []string{ + filepath.Join(root, "internal", "metadata", "thexem"), + filepath.Join(root, "internal", "services", "trackericon"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("changedPackages() = %v, want %v", got, want) + } +} + +func TestFilterDiffKeepsOnlyChangedFiles(t *testing.T) { + t.Parallel() + + root := t.TempDir() + changedPath := filepath.Join(root, "internal", "metadata", "thexem", "changed.go") + unchangedPath := filepath.Join(root, "internal", "metadata", "thexem", "client.go") + diff := strings.Join([]string{ + "--- " + changedPath + " (old)", + "+++ " + changedPath + " (new)", + "@@ -1 +1 @@", + "-old", + "+new", + "--- " + unchangedPath + " (old)", + "+++ " + unchangedPath + " (new)", + "@@ -1 +1 @@", + "-old runtime form", + "+modern runtime form", + "", + }, "\n") + + got, err := filterDiff([]byte(diff), root, []string{"internal/metadata/thexem/changed.go"}) + if err != nil { + t.Fatalf("filterDiff() error = %v", err) + } + gotText := string(got) + if !strings.Contains(gotText, "+++ b/internal/metadata/thexem/changed.go") { + t.Fatalf("filtered diff omitted changed file:\n%s", gotText) + } + if strings.Contains(gotText, "client.go") || strings.Contains(gotText, "modern runtime form") { + t.Fatalf("filtered diff retained unchanged file:\n%s", gotText) + } +} diff --git a/cmd/literalpolicy/main.go b/cmd/literalpolicy/main.go new file mode 100644 index 00000000..76133437 --- /dev/null +++ b/cmd/literalpolicy/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/autobrr/upbrr/internal/literalpolicy" +) + +func main() { + fix := flag.Bool("fix", false, "rewrite inconsistent keyed composite literals") + flag.Parse() + root, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "literalpolicy: determine working directory: %v\n", err) + os.Exit(1) + } + if *fix { + fixed, fixErr := literalpolicy.FixRepository(root) + if fixErr != nil { + fmt.Fprintf(os.Stderr, "literalpolicy: %v\n", fixErr) + os.Exit(1) + } + fmt.Printf("literalpolicy: fixed %d files\n", fixed) + return + } + violations, err := literalpolicy.CheckRepository(root) + if err != nil { + fmt.Fprintf(os.Stderr, "literalpolicy: %v\n", err) + os.Exit(1) + } + for _, violation := range violations { + fmt.Println(violation.String()) + } + if len(violations) != 0 { + os.Exit(1) + } + fmt.Println("literalpolicy: no issues found") +} diff --git a/cmd/pathpolicy/main.go b/cmd/pathpolicy/main.go index 9cfff4c6..a8bf5fc5 100644 --- a/cmd/pathpolicy/main.go +++ b/cmd/pathpolicy/main.go @@ -5,7 +5,7 @@ import ( "io" "os" - "github.com/autobrr/upbrr/internal/pathpolicy" + pathpolicy "github.com/autobrr/upbrr/internal/pathing/policy" ) func main() { diff --git a/cmd/pathpolicy/main_test.go b/cmd/pathpolicy/main_test.go index 6d371a5a..d30c32e4 100644 --- a/cmd/pathpolicy/main_test.go +++ b/cmd/pathpolicy/main_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/autobrr/upbrr/internal/pathpolicy" + pathpolicy "github.com/autobrr/upbrr/internal/pathing/policy" ) func TestRunPrintsSuccessWhenNoIssuesFound(t *testing.T) { diff --git a/cmd/upbrr/AGENTS.md b/cmd/upbrr/AGENTS.md index 63c118ce..fe2a34dd 100644 --- a/cmd/upbrr/AGENTS.md +++ b/cmd/upbrr/AGENTS.md @@ -25,16 +25,16 @@ Add touched service/tracker/internal packages to focused `go test` runs. - Follow root log-level guidance for CLI logs and stdout/stderr. - User-facing stdout/stderr may be copied into issues. Do not print credentials, usernames, passwords, tokens, API keys, auth keys, passkeys, cookie values, 2FA codes, challenge IDs, refreshed API tokens, or secret payloads. -- CLI dry-run stdout is shareable debug material: redact endpoints and payload values with the existing safe dry-run helpers before printing. +- CLI debug payload stdout is shareable diagnostic material: redact endpoints and payload values with the existing safe dry-run helpers before printing. - Tracker-auth CLI logs should make the pre-dupe auth flow reconstructable without secrets: capability load count, auth-check start with tracker count, validation start per managed tracker with `tracker` and `auth_kind`, status result with `state`, cookie count, encrypted-storage availability and `needs_2fa`, per-tracker decision, and final ready/skipped counts. - Decision values should be stable and searchable: `ready`, `keep`, `skip`, `blocked`, `prompt_2fa`, or `prompt_2fa_again`. - Log unattended blocks as warnings with `unattended=true` and a stable reason such as `2fa_required`. - Log non-fatal filtering details, such as auth skipped because preview rule failures already excluded a tracker, at debug level. - Log prompt failures, blank 2FA submissions, failed 2FA submissions, and auth-not-ready skips at warning level. - Redact every free-form status/error field before logging. Keep empty fields readable with a stable value such as `none`. -- Run `make logpolicy` when changing CLI dry-run output or auth-sensitive output/logging. +- Run `make logpolicy` when changing CLI debug payload output or auth-sensitive output/logging. ## Parity -- CLI request/options behavior shares contracts with Wails and embedded web. -- Request-shape changes usually require checking `pkg/api`, `internal/core`, `internal/guiapp`, `internal/webserver`, and `gui/frontend/src`. +- CLI request/options behavior shares contracts with WebUI workflows. +- Request-shape changes usually require checking `pkg/api`, `internal/core`, `internal/webserver`, and `webui/src`. diff --git a/cmd/upbrr/cli_options.go b/cmd/upbrr/cli_options.go index 677c179c..f3250347 100644 --- a/cmd/upbrr/cli_options.go +++ b/cmd/upbrr/cli_options.go @@ -15,7 +15,8 @@ import ( "strconv" "strings" - "github.com/autobrr/upbrr/internal/imagehostpolicy" + imagehostpolicy "github.com/autobrr/upbrr/internal/imagehosting/policy" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) @@ -30,7 +31,6 @@ type cliOptions struct { TrackersRemove string Debug bool LogLevel string - DryRun bool Screens int NoSeed bool SkipAutoTorrent bool @@ -164,7 +164,6 @@ func parseCLIOptions(args []string) (cliOptions, map[string]bool, []string, erro fs.StringVar(&opts.TrackersRemove, "rtk", "", "Remove these trackers (comma-separated)") fs.BoolVar(&opts.Debug, "debug", false, "Enable debug mode") fs.StringVar(&opts.LogLevel, "log-level", "", "Set run log level (error, warn, info, debug, trace)") - fs.BoolVar(&opts.DryRun, "dry-run", false, "Run without uploading") fs.IntVar(&opts.Screens, "screens", -1, "Number of screenshots to take") fs.IntVar(&opts.Screens, "s", -1, "Number of screenshots to take") fs.BoolVar(&opts.NoSeed, "no-seed", false, "Do not inject torrent into clients") @@ -607,7 +606,7 @@ func cliHelpSections(name string) []helpSection { {title: "Config", names: []string{"config", "export-config", "export-config-plaintext", "import-config", "create-auth"}}, {title: "Application", names: []string{"version", "cleanup"}}, {title: "Execution", names: []string{ - "queue", "limit-queue", "site-check", "site-upload", "dry-run", "debug", "log-level", "upload-only", + "queue", "limit-queue", "site-check", "site-upload", "debug", "log-level", "upload-only", "delete-tmp", "unattended", "unattended_confirm", }}, {title: "Tracker Selection", names: []string{"trackers", "trackers-remove"}}, @@ -713,9 +712,12 @@ func buildCLIRequest(opts cliOptions, visited map[string]bool, paths []string, s runLogLevel = normalized } + sourcePath := "" + if len(paths) == 1 { + sourcePath = paths[0] + } req := api.Request{ - Paths: paths, - Mode: api.ModeCLI, + SourcePath: sourcePath, Execution: api.ExecutionOptions{ QueueName: strings.TrimSpace(opts.QueueName), QueueLimit: opts.LimitQueue, @@ -725,8 +727,6 @@ func buildCLIRequest(opts cliOptions, visited map[string]bool, paths []string, s Trackers: splitCSV(opts.Trackers), TrackersRemove: splitCSV(opts.TrackersRemove), Options: api.UploadOptions{ - Debug: opts.Debug, - DryRun: opts.DryRun || opts.Debug || opts.SiteCheck, RunLogLevel: runLogLevel, Screens: screens, NoSeed: opts.NoSeed, @@ -948,15 +948,51 @@ func buildTrackerIDOverrides(opts cliOptions, visited map[string]bool) (map[stri tracker string value string }{ - {visitedName: "ptp", tracker: "ptp", value: opts.PTP}, - {visitedName: "blu", tracker: "blu", value: opts.BLU}, - {visitedName: "aither", tracker: "aither", value: opts.Aither}, - {visitedName: "lst", tracker: "lst", value: opts.LST}, - {visitedName: "oe", tracker: "oe", value: opts.OE}, - {visitedName: "hdb", tracker: "hdb", value: opts.HDB}, - {visitedName: "btn", tracker: "btn", value: opts.BTN}, - {visitedName: "bhd", tracker: "bhd", value: opts.BHD}, - {visitedName: "ulcx", tracker: "ulcx", value: opts.ULCX}, + { + visitedName: "ptp", + tracker: "ptp", + value: opts.PTP, + }, + { + visitedName: "blu", + tracker: "blu", + value: opts.BLU, + }, + { + visitedName: "aither", + tracker: "aither", + value: opts.Aither, + }, + { + visitedName: "lst", + tracker: "lst", + value: opts.LST, + }, + { + visitedName: "oe", + tracker: "oe", + value: opts.OE, + }, + { + visitedName: "hdb", + tracker: "hdb", + value: opts.HDB, + }, + { + visitedName: "btn", + tracker: "btn", + value: opts.BTN, + }, + { + visitedName: "bhd", + tracker: "bhd", + value: opts.BHD, + }, + { + visitedName: "ulcx", + tracker: "ulcx", + value: opts.ULCX, + }, } overrides := make(map[string]string) @@ -1078,8 +1114,11 @@ func parseInfoHash(raw string) (string, error) { func parseImageHost(raw string) (string, error) { trimmed := strings.ToLower(strings.TrimSpace(raw)) - if imagehostpolicy.IsUploadHost(trimmed) && imagehostpolicy.OwnerForHost(trimmed) == "" { - return trimmed, nil + if imagehostpolicy.IsUploadHost(trimmed) { + registry, err := trackerimpl.NewRegistry() + if err == nil && registry.OwnerForImageHost(trimmed) == "" { + return trimmed, nil + } } return "", fmt.Errorf("invalid imghost %q", raw) } diff --git a/cmd/upbrr/cli_options_test.go b/cmd/upbrr/cli_options_test.go index 33c47ec3..7a43230e 100644 --- a/cmd/upbrr/cli_options_test.go +++ b/cmd/upbrr/cli_options_test.go @@ -112,9 +112,21 @@ func TestParseServeOptionsPortUsesDecimalSyntax(t *testing.T) { args []string want int }{ - {name: "separate value", args: []string{"--port", "080"}, want: 80}, - {name: "equals value", args: []string{"--port=010"}, want: 10}, - {name: "leading zero nine", args: []string{"--port", "009"}, want: 9}, + { + name: "separate value", + args: []string{"--port", "080"}, + want: 80, + }, + { + name: "equals value", + args: []string{"--port=010"}, + want: 10, + }, + { + name: "leading zero nine", + args: []string{"--port", "009"}, + want: 9, + }, } for _, tc := range cases { @@ -170,10 +182,30 @@ func TestApplyServeOptionOverridesAddressMatrix(t *testing.T) { host string port int }{ - {name: "host port", addr: "localhost:9090", host: "localhost", port: 9090}, - {name: "colon port shorthand", addr: ":9091", host: "0.0.0.0", port: 9091}, - {name: "bracketed ipv6", addr: "[::1]:9092", host: "::1", port: 9092}, - {name: "scoped ipv6", addr: "[fe80::1%zone]:9093", host: "fe80::1%zone", port: 9093}, + { + name: "host port", + addr: "localhost:9090", + host: "localhost", + port: 9090, + }, + { + name: "colon port shorthand", + addr: ":9091", + host: "0.0.0.0", + port: 9091, + }, + { + name: "bracketed ipv6", + addr: "[::1]:9092", + host: "::1", + port: 9092, + }, + { + name: "scoped ipv6", + addr: "[fe80::1%zone]:9093", + host: "fe80::1%zone", + port: 9093, + }, } for _, tc := range cases { @@ -255,16 +287,32 @@ func TestApplyServeEnvOverridesRejectsEmptyBaseURL(t *testing.T) { func TestApplyServeOptionOverridesCLIOverridesEnv(t *testing.T) { envCfg, err := applyServeEnvOverrides( webserver.DefaultCLIConfig(), - serveEnvOptions{Host: "0.0.0.0", Port: "9090", BaseURL: "/env/"}, - map[string]bool{"host": true, "port": true, "base-url": true}, + serveEnvOptions{ + Host: "0.0.0.0", + Port: "9090", + BaseURL: "/env/", + }, + map[string]bool{ + "host": true, + "port": true, + "base-url": true, + }, ) if err != nil { t.Fatalf("apply serve env overrides: %v", err) } cfg, err := applyServeOptionOverrides( envCfg, - serveOptions{Host: "127.0.0.1", Port: 9191, BaseURL: "/cli/"}, - map[string]bool{"host": true, "port": true, "base-url": true}, + serveOptions{ + Host: "127.0.0.1", + Port: 9191, + BaseURL: "/cli/", + }, + map[string]bool{ + "host": true, + "port": true, + "base-url": true, + }, ) if err != nil { t.Fatalf("apply serve option overrides: %v", err) @@ -349,15 +397,60 @@ func TestApplyServeOptionOverridesRejectsInvalidValues(t *testing.T) { visited map[string]bool want string }{ - {name: "addr with host", opts: serveOptions{Addr: "localhost:7480", Host: "localhost"}, visited: map[string]bool{"addr": true, "host": true}, want: "--addr cannot be used"}, - {name: "empty host", opts: serveOptions{Host: " "}, visited: map[string]bool{"host": true}, want: "--host cannot be empty"}, - {name: "host includes port", opts: serveOptions{Host: "localhost:7480"}, visited: map[string]bool{"host": true}, want: "--host cannot include a port"}, - {name: "scoped ipv6 hostport", opts: serveOptions{Host: "fe80::1%zone:9090"}, visited: map[string]bool{"host": true}, want: "--host cannot include a port"}, - {name: "invalid port", opts: serveOptions{Port: 70000}, visited: map[string]bool{"port": true}, want: "invalid port"}, - {name: "invalid addr", opts: serveOptions{Addr: "localhost"}, visited: map[string]bool{"addr": true}, want: "--addr must be host:port"}, - {name: "unbracketed ipv6 addr", opts: serveOptions{Addr: "::1:9090"}, visited: map[string]bool{"addr": true}, want: "--addr must be host:port"}, - {name: "empty base url", opts: serveOptions{BaseURL: " "}, visited: map[string]bool{"base-url": true}, want: "--base-url cannot be empty"}, - {name: "invalid base url", opts: serveOptions{BaseURL: "javascript:alert(1)"}, visited: map[string]bool{"base-url": true}, want: "http or https"}, + { + name: "addr with host", + opts: serveOptions{Addr: "localhost:7480", Host: "localhost"}, + visited: map[string]bool{"addr": true, "host": true}, + want: "--addr cannot be used", + }, + { + name: "empty host", + opts: serveOptions{Host: " "}, + visited: map[string]bool{"host": true}, + want: "--host cannot be empty", + }, + { + name: "host includes port", + opts: serveOptions{Host: "localhost:7480"}, + visited: map[string]bool{"host": true}, + want: "--host cannot include a port", + }, + { + name: "scoped ipv6 hostport", + opts: serveOptions{Host: "fe80::1%zone:9090"}, + visited: map[string]bool{"host": true}, + want: "--host cannot include a port", + }, + { + name: "invalid port", + opts: serveOptions{Port: 70000}, + visited: map[string]bool{"port": true}, + want: "invalid port", + }, + { + name: "invalid addr", + opts: serveOptions{Addr: "localhost"}, + visited: map[string]bool{"addr": true}, + want: "--addr must be host:port", + }, + { + name: "unbracketed ipv6 addr", + opts: serveOptions{Addr: "::1:9090"}, + visited: map[string]bool{"addr": true}, + want: "--addr must be host:port", + }, + { + name: "empty base url", + opts: serveOptions{BaseURL: " "}, + visited: map[string]bool{"base-url": true}, + want: "--base-url cannot be empty", + }, + { + name: "invalid base url", + opts: serveOptions{BaseURL: "javascript:alert(1)"}, + visited: map[string]bool{"base-url": true}, + want: "http or https", + }, } for _, tc := range cases { @@ -378,11 +471,31 @@ func TestParseServeHostRejectsMalformedBrackets(t *testing.T) { host string want string }{ - {name: "leading bracket only", host: "[::1", want: "invalid bracket syntax"}, - {name: "trailing bracket only", host: "::1]", want: "invalid bracket syntax"}, - {name: "nested brackets", host: "[[::1]]", want: "invalid bracket syntax"}, - {name: "empty brackets", host: "[]", want: "invalid bracket syntax"}, - {name: "bracketed hostname", host: "[localhost]", want: "IPv6 literals"}, + { + name: "leading bracket only", + host: "[::1", + want: "invalid bracket syntax", + }, + { + name: "trailing bracket only", + host: "::1]", + want: "invalid bracket syntax", + }, + { + name: "nested brackets", + host: "[[::1]]", + want: "invalid bracket syntax", + }, + { + name: "empty brackets", + host: "[]", + want: "invalid bracket syntax", + }, + { + name: "bracketed hostname", + host: "[localhost]", + want: "IPv6 literals", + }, } for _, tc := range cases { @@ -442,12 +555,6 @@ func TestParseServeHostPreservesValidIPv6(t *testing.T) { } } -func TestParseCLIOptionsRejectsGUIFlag(t *testing.T) { - if _, _, _, err := parseCLIOptions([]string{"-gui"}); err == nil { - t.Fatal("expected gui flag to be rejected") - } -} - func TestParseCLIOptionsExportConfigPlaintext(t *testing.T) { opts, visited, paths, err := parseCLIOptions([]string{"--export-config", "out.yaml", "--export-config-plaintext"}) if err != nil { @@ -513,7 +620,7 @@ func TestParseCLIOptionsPythonAliases(t *testing.T) { } } -func TestBuildCLIRequestDebugImpliesDryRunAndOnlyID(t *testing.T) { +func TestBuildCLIRequestKeepsDebugAdapterLocalAndPropagatesOnlyID(t *testing.T) { opts, visited, paths, err := parseCLIOptions([]string{"--debug", "--onlyID", "movie.mkv"}) if err != nil { t.Fatalf("parse: %v", err) @@ -522,11 +629,8 @@ func TestBuildCLIRequestDebugImpliesDryRunAndOnlyID(t *testing.T) { if err != nil { t.Fatalf("build request: %v", err) } - if !req.Options.Debug { - t.Fatalf("expected debug enabled, got %#v", req.Options) - } - if !req.Options.DryRun { - t.Fatalf("expected debug to imply dry run, got %#v", req.Options) + if !opts.Debug { + t.Fatal("expected CLI debug mode enabled") } if !req.Options.OnlyID { t.Fatalf("expected onlyID to propagate, got %#v", req.Options) @@ -550,12 +654,11 @@ func TestParseCLIOptionsFlagsAfterPath(t *testing.T) { if !visited["debug"] || !visited["delete-tmp"] { t.Fatalf("expected trailing flags visited, got %#v", visited) } - req, err := buildCLIRequest(opts, visited, paths, 4) - if err != nil { + if _, err := buildCLIRequest(opts, visited, paths, 4); err != nil { t.Fatalf("build request: %v", err) } - if !req.Options.Debug || !req.Options.DryRun { - t.Fatalf("expected trailing debug to force dry run, got %#v", req.Options) + if !opts.Debug { + t.Fatal("expected trailing debug to remain adapter-local") } } @@ -596,8 +699,43 @@ func TestBuildCLIRequestPropagatesRunLogLevel(t *testing.T) { if req.Options.RunLogLevel != "trace" { t.Fatalf("expected run log level trace, got %q", req.Options.RunLogLevel) } - if !req.Options.Debug { - t.Fatalf("expected debug enabled, got %#v", req.Options) + if !opts.Debug { + t.Fatal("expected CLI debug mode enabled") + } +} + +func TestBuildCLIRequestPreservesCombinedDebugModifiers(t *testing.T) { + for _, test := range []struct { + name string + args []string + wantNoSeed bool + }{ + {name: "default injection", args: []string{"--debug", "-siu", "-s", "0", "movie.mkv"}}, + { +name: "no seed", + args: []string{"--debug", "-ns", "-siu", "-s", "0", "movie.mkv"}, + wantNoSeed: true, +}, + } { + t.Run(test.name, func(t *testing.T) { + opts, visited, paths, err := parseCLIOptions(test.args) + if err != nil { + t.Fatalf("parse: %v", err) + } + req, err := buildCLIRequest(opts, visited, paths, opts.Screens) + if err != nil { + t.Fatalf("build request: %v", err) + } + if !opts.Debug { + t.Fatal("expected debug to remain an adapter-local operation choice") + } + if req.Options.NoSeed != test.wantNoSeed || req.Options.Screens != 0 { + t.Fatalf("combined debug modifiers lost from upload options: %#v", req.Options) + } + if req.ImageHostOverrides.SkipUpload == nil || !*req.ImageHostOverrides.SkipUpload { + t.Fatalf("combined debug image-host override lost: %#v", req.ImageHostOverrides) + } + }) } } @@ -676,14 +814,19 @@ func TestBuildCLIRequestExecutionOptions(t *testing.T) { if req.Execution.SiteUploadTracker != "BLU" { t.Fatalf("expected site upload tracker BLU, got %q", req.Execution.SiteUploadTracker) } - if !req.Options.DryRun { - t.Fatalf("expected site-check to imply dry run, got %#v", req.Options) - } if len(req.Trackers) != 1 || req.Trackers[0] != "BLU" { t.Fatalf("expected site-upload tracker to override request trackers, got %#v", req.Trackers) } } +func TestParseCLIOptionsRejectsRemovedDryRunFlag(t *testing.T) { + t.Parallel() + + if _, _, _, err := parseCLIOptions([]string{"--dry-run", "movie.mkv"}); err == nil { + t.Fatal("expected removed --dry-run flag to be rejected") + } +} + func TestParseCLIOptionsRejectsNegativeQueueLimit(t *testing.T) { if _, _, _, err := parseCLIOptions([]string{"--limit-queue", "-1", "movie.mkv"}); err == nil { t.Fatal("expected negative limit-queue to fail") @@ -984,26 +1127,30 @@ func TestPrintMetadataPreviewShowsRichReleaseDetails(t *testing.T) { SourcePath: "C:\\path\\to\\Example.Release.2026.S01.1080p.WEB-DL-GRP", ReleaseName: "Example Release 2026 S01 1080p WEB-DL H.264-GRP", TrackerName: "LST", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ TMDBID: 123456, IMDBID: 7654321, TVDBID: 234567, TVmazeID: 34567, - Category: "TV", + Category: api.CanonicalCategoryTV, }, - Warnings: []string{"Season pack contains mixed group tags (ALT, GRP); trackers with mixed-origin support will use Mixed."}, - ExternalPreview: []api.ExternalPreview{{ - Provider: "tmdb", - ID: 123456, - Title: "Example Release", - Year: 2026, - Overview: "Example overview for a fictional series used in CLI preview output.", - Genres: "Drama, Mystery", - Category: "TV", - FirstAirDate: "2026-02-22", - Runtime: 55, - Rating: 7.9, - RatingCount: 1200, + Display: api.PreparedReleaseDisplay{Providers: []api.ProviderDisplay{{ + Provider: api.IdentityProviderTMDB, + ID: 123456, + SummaryAvailable: true, + Summary: api.ProviderDisplaySummary{ + Title: "Example Release", + Year: 2026, + Overview: "Example overview for a fictional series used in CLI preview output.", + Genres: "Drama, Mystery", + Category: "TV", + Date: "2026-02-22", + RuntimeMinutes: 55, + }, + }}}, + Diagnostics: []api.PreparationDiagnostic{{ + Severity: api.DiagnosticSeverityWarning, + Message: "Season pack contains mixed group tags (ALT, GRP); trackers with mixed-origin support will use Mixed.", }}, }, true) }) @@ -1020,13 +1167,12 @@ func TestPrintMetadataPreviewShowsRichReleaseDetails(t *testing.T) { "Category: TV", "Date: 2026-02-22", "Runtime: 55 min", - "Rating: 7.9 (1200 votes)", "Tracker data from: LST", "External IDs", - "TMDB: https://www.themoviedb.org/tv/123456", - "IMDb: https://www.imdb.com/title/tt7654321", - "TVDB: https://www.thetvdb.com/?id=234567&tab=series", - "TVmaze: https://www.tvmaze.com/shows/34567", + "TMDB: 123456", + "IMDb: tt7654321", + "TVDB: 234567", + "TVmaze: 34567", "Warnings:", "- Season pack contains mixed group tags (ALT, GRP); trackers with mixed-origin support will use Mixed.", } { @@ -1048,8 +1194,16 @@ func TestPrintDryRunDetails(t *testing.T) { entry: api.TrackerDryRunEntry{ Endpoint: "https://tracker.test/upload", Files: []api.TrackerDryRunFile{ - {Field: "torrent", Path: "C:\\Users\\Tester\\.upbrr\\tmp\\file.torrent", Present: true}, - {Field: "nfo", Path: "", Present: false}, + { + Field: "torrent", + Path: "C:\\Users\\Tester\\.upbrr\\tmp\\file.torrent", + Present: true, + }, + { + Field: "nfo", + Path: "", + Present: false, + }, }, Payload: map[string]string{ "category": "MOVIE", @@ -1104,7 +1258,11 @@ func TestPrintDryRunDetails(t *testing.T) { Title: "BTN final upload payload after autofill", Endpoint: "https://tracker.test/upload.php?api_key=secret-key", Files: []api.TrackerDryRunFile{ - {Field: "file_input", Path: "C:\\Users\\Tester\\.upbrr\\tmp\\file.torrent", Present: true}, + { + Field: "file_input", + Path: "C:\\Users\\Tester\\.upbrr\\tmp\\file.torrent", + Present: true, + }, }, Payload: map[string]string{ "artist": "Example Show", diff --git a/cmd/upbrr/cli_tracker_auth_default.go b/cmd/upbrr/cli_tracker_auth_default.go index 1086044c..613e63ca 100644 --- a/cmd/upbrr/cli_tracker_auth_default.go +++ b/cmd/upbrr/cli_tracker_auth_default.go @@ -7,11 +7,12 @@ package main import ( "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackerauth" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) // newCLITrackerAuthService returns the production tracker-auth implementation. func newCLITrackerAuthService(cfg config.Config, logger api.Logger) cliTrackerAuthService { - return trackerauth.NewServiceWithLogger(cfg, logger) + return trackerauth.NewServiceWithRegistryAndLogger(cfg, trackerimpl.MustNewRegistry(), logger) } diff --git a/cmd/upbrr/cli_tracker_auth_e2e.go b/cmd/upbrr/cli_tracker_auth_e2e.go index fbe2a09b..c109a86c 100644 --- a/cmd/upbrr/cli_tracker_auth_e2e.go +++ b/cmd/upbrr/cli_tracker_auth_e2e.go @@ -11,7 +11,8 @@ import ( "strings" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackerauth" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) @@ -24,7 +25,7 @@ func newCLITrackerAuthService(cfg config.Config, logger api.Logger) cliTrackerAu if value == "1" || strings.EqualFold(value, "true") { return e2eCLITrackerAuthService{} } - return trackerauth.NewServiceWithLogger(cfg, logger) + return trackerauth.NewServiceWithRegistryAndLogger(cfg, trackerimpl.MustNewRegistry(), logger) } // e2eCLITrackerAuthService treats tracker auth as configured without network IO. diff --git a/cmd/upbrr/description_editor.go b/cmd/upbrr/description_editor.go index 23fc4331..4b4a782b 100644 --- a/cmd/upbrr/description_editor.go +++ b/cmd/upbrr/description_editor.go @@ -19,22 +19,35 @@ import ( var editCLIDescriptionFile = editDescriptionFile -func maybeEditCLIDescriptions(ctx context.Context, coreSvc api.Core, reader *bufio.Reader, req api.Request, review api.UploadReview, opts cliOptions) (api.Request, api.UploadReview, error) { +// cliDescriptionEditorCore owns description preview and persisted overrides. +type cliDescriptionEditorCore interface { + FetchDescriptionBuilderPreview(context.Context, api.Request) (api.DescriptionBuilderPreview, error) + SaveDescriptionOverride(context.Context, api.Request, string) (api.DescriptionBuilderGroup, error) +} + +func maybeEditCLIDescriptions( + ctx context.Context, + coreSvc cliDescriptionEditorCore, + reader *bufio.Reader, + req api.Request, + review api.UploadReview, + opts cliOptions, +) (api.Request, error) { if req.Options.OnlyID || opts.OnlyID { - return req, review, nil + return req, nil } if opts.Unattended && !opts.UnattendedConfirm { - return req, review, nil + return req, nil } preview, err := coreSvc.FetchDescriptionBuilderPreview(ctx, req) if err != nil { - return req, review, fmt.Errorf("upbrr: description builder: %w", err) + return req, fmt.Errorf("upbrr: description builder: %w", err) } groups := filterEditableDescriptionGroups(preview.Groups, review) if len(groups) == 0 { fmt.Println("No generated descriptions available to edit.") - return req, review, nil + return req, nil } printCLIDescriptionPreview(groups) @@ -48,7 +61,7 @@ func maybeEditCLIDescriptions(ctx context.Context, coreSvc api.Core, reader *buf } edit, err := promptYesNo(reader, prompt, false) if err != nil { - return req, review, err + return req, err } if !edit { continue @@ -57,7 +70,7 @@ func maybeEditCLIDescriptions(ctx context.Context, coreSvc api.Core, reader *buf fmt.Printf("Editing description %s\n", label) edited, didChange, err := editCLIDescriptionFile(ctx, group.RawDescription) if err != nil { - return req, review, fmt.Errorf("upbrr: edit description %s: %w", label, err) + return req, fmt.Errorf("upbrr: edit description %s: %w", label, err) } if !didChange { fmt.Printf("Description %s unchanged.\n", label) @@ -71,22 +84,18 @@ func maybeEditCLIDescriptions(ctx context.Context, coreSvc api.Core, reader *buf } updated, err := coreSvc.SaveDescriptionOverride(ctx, saveReq, edited) if err != nil { - return req, review, fmt.Errorf("upbrr: save description %s: %w", label, err) + return req, fmt.Errorf("upbrr: save description %s: %w", label, err) } groups[idx] = mergeDescriptionGroupUpdate(group, updated) changed = true fmt.Printf("Description %s saved.\n", label) } if !changed { - return req, review, nil + return req, nil } req.DescriptionGroups = replaceDescriptionGroups(preview.Groups, groups) - updatedReview, err := coreSvc.BuildUploadReview(ctx, req) - if err != nil { - return req, review, fmt.Errorf("upbrr: rebuild upload review: %w", err) - } - return req, updatedReview, nil + return req, nil } func filterEditableDescriptionGroups(groups []api.DescriptionBuilderGroup, review api.UploadReview) []api.DescriptionBuilderGroup { diff --git a/cmd/upbrr/interactive.go b/cmd/upbrr/interactive.go index 4459bb57..3268346b 100644 --- a/cmd/upbrr/interactive.go +++ b/cmd/upbrr/interactive.go @@ -18,8 +18,9 @@ import ( "github.com/autobrr/upbrr/internal/config" "github.com/autobrr/upbrr/internal/logging" "github.com/autobrr/upbrr/internal/redaction" - "github.com/autobrr/upbrr/internal/trackerauth" "github.com/autobrr/upbrr/internal/trackers" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) @@ -35,23 +36,132 @@ type cliTrackerAuthService interface { Submit2FA(ctx context.Context, challengeID string, code string) (api.TrackerAuthStatus, error) } -func runInteractiveCLIPath(ctx context.Context, coreSvc api.Core, opts cliOptions, visited map[string]bool, sourcePath string, cfg config.Config) error { +// cliSiteCheckCore composes metadata preparation and review for site-check-only runs. +type cliSiteCheckCore interface { + cliMetadataPreviewer + cliUploadReviewer +} + +// cliScreenshotCore is the CLI-facing screenshot seam. +type cliScreenshotCore interface { + FetchScreenshotPlan(context.Context, api.Request) (api.ScreenshotPlan, error) + GenerateScreenshots(context.Context, api.Request, []api.ScreenshotSelection, api.ScreenshotPurpose) (api.ScreenshotResult, error) + SaveFinalScreenshotSelections(context.Context, api.Request, []api.ScreenshotImage) error +} + +type cliDupeChecker interface { + CheckDupes(context.Context, api.Request) (api.DupeCheckSummary, error) +} + +// cliInteractiveCore composes only capabilities used by the interactive path. +type cliInteractiveCore interface { + cliPreparedUploadRunner + cliAcceptedTrackerDryRunRunner + cliMetadataPreviewer + cliUploadReviewer + cliScreenshotCore + cliDVDMenuCore + cliDupeChecker + cliDescriptionEditorCore +} + +func runInteractiveCLIPath( + ctx context.Context, + coreSvc cliInteractiveCore, + opts cliOptions, + visited map[string]bool, + sourcePath string, + cfg config.Config, +) error { return runInteractiveCLIPathWithInputAndLogger(ctx, coreSvc, nil, opts, visited, sourcePath, 1, cfg, os.Stdin, api.NopLogger{}) } -// runInteractiveCLIPathWithLogger runs one interactive CLI upload path and -// sends CLI-only tracker auth decisions to logger. -func runInteractiveCLIPathWithLogger(ctx context.Context, coreSvc api.Core, baseArgs []string, opts cliOptions, visited map[string]bool, sourcePath string, screens int, cfg config.Config, logger api.Logger) error { - return runInteractiveCLIPathWithInputAndLogger(ctx, coreSvc, baseArgs, opts, visited, sourcePath, screens, cfg, os.Stdin, logger) +// runInteractiveCLIPathWithLoggerAndPlaylist runs the interactive workflow with +// one pre-resolved playlist instruction and process stdin. +func runInteractiveCLIPathWithLoggerAndPlaylist( + ctx context.Context, + coreSvc cliInteractiveCore, + baseArgs []string, + opts cliOptions, + visited map[string]bool, + sourcePath string, + playlist api.PlaylistInstruction, + screens int, + cfg config.Config, + logger api.Logger, +) error { + return runInteractiveCLIPathWithInputLoggerAndPlaylist( + ctx, + coreSvc, + baseArgs, + opts, + visited, + sourcePath, + playlist, + screens, + cfg, + os.Stdin, + logger, + ) } -func runInteractiveCLIPathWithInput(ctx context.Context, coreSvc api.Core, opts cliOptions, visited map[string]bool, sourcePath string, screens int, cfg config.Config, stdin io.Reader) error { +func runInteractiveCLIPathWithInput( + ctx context.Context, + coreSvc cliInteractiveCore, + opts cliOptions, + visited map[string]bool, + sourcePath string, + screens int, + cfg config.Config, + stdin io.Reader, +) error { return runInteractiveCLIPathWithInputAndLogger(ctx, coreSvc, nil, opts, visited, sourcePath, screens, cfg, stdin, api.NopLogger{}) } -// runInteractiveCLIPathWithInputAndLogger is the injectable form of -// runInteractiveCLIPathWithLogger used when tests need controlled stdin. -func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Core, baseArgs []string, opts cliOptions, visited map[string]bool, sourcePath string, screens int, cfg config.Config, stdin io.Reader, logger api.Logger) error { +// runInteractiveCLIPathWithInputAndLogger is the injectable interactive runner +// used when tests need controlled stdin and logging. +func runInteractiveCLIPathWithInputAndLogger( + ctx context.Context, + coreSvc cliInteractiveCore, + baseArgs []string, + opts cliOptions, + visited map[string]bool, + sourcePath string, + screens int, + cfg config.Config, + stdin io.Reader, + logger api.Logger, +) error { + return runInteractiveCLIPathWithInputLoggerAndPlaylist( + ctx, + coreSvc, + baseArgs, + opts, + visited, + sourcePath, + api.PlaylistInstruction{}, + screens, + cfg, + stdin, + logger, + ) +} + +// runInteractiveCLIPathWithInputLoggerAndPlaylist owns the interactive retry +// loop while preserving the source-specific playlist instruction across retries. +func runInteractiveCLIPathWithInputLoggerAndPlaylist( + ctx context.Context, + coreSvc cliInteractiveCore, + baseArgs []string, + opts cliOptions, + visited map[string]bool, + sourcePath string, + playlist api.PlaylistInstruction, + screens int, + cfg config.Config, + stdin io.Reader, + logger api.Logger, +) error { logger = cliAuthLogger(logger) reader := bufio.NewReader(stdin) currentArgs := append([]string(nil), baseArgs...) @@ -64,11 +174,19 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co if err != nil { return err } + req.PlaylistInstruction = cloneCLIPlaylistInstruction(playlist) preview, err := coreSvc.FetchMetadataPreview(ctx, req) if err != nil { var rescanErr *api.BDMVRescanRequiredError if errors.As(err, &rescanErr) && currentOpts.interactionMode() != api.InteractionModeUnattended { - confirm, promptErr := promptYesNo(reader, fmt.Sprintf("Cached BDMV summaries exist, but selected playlist(s) %s require a rescan. Rescan now? [Y/n]: ", strings.Join(rescanErr.MissingPlaylists, ", ")), true) + confirm, promptErr := promptYesNo( + reader, + fmt.Sprintf( + "Cached BDMV summaries exist, but selected playlist(s) %s require a rescan. Rescan now? [Y/n]: ", + strings.Join(rescanErr.MissingPlaylists, ", "), + ), + true, + ) if promptErr != nil { return promptErr } @@ -128,14 +246,30 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co if err != nil { return err } + req.PlaylistInstruction = cloneCLIPlaylistInstruction(playlist) - candidateTrackers, removalBase := resolveCLIUploadTrackers(currentVisited, req, metadataPreview, cfg) + registry, err := trackerimpl.NewRegistry() + if err != nil { + return fmt.Errorf("upbrr: compose tracker registry: %w", err) + } + candidateTrackers, removalBase := resolveCLIUploadTrackers(currentVisited, req, metadataPreview, cfg, registry, currentOpts.Debug) if len(candidateTrackers) == 0 { fmt.Printf("No trackers configured for %s\n", formatPathLabel(sourcePath)) return nil } req.Trackers = candidateTrackers req.TrackersRemove = appendTrackerRemovals(req.TrackersRemove, unselectedTrackers(removalBase, candidateTrackers)...) + if !currentOpts.Debug { + candidateTrackers, req.RuleAuthorizations, err = promptTrackerRuleReview(reader, req, candidateTrackers, metadataPreview) + if err != nil { + return err + } + if len(candidateTrackers) == 0 { + fmt.Printf("No trackers selected for %s\n", formatPathLabel(sourcePath)) + return nil + } + req.Trackers = candidateTrackers + } readyAuthTrackers, err := ensureCLITrackerAuthBeforeDupeCheckWithLogger(ctx, reader, cfg, req, candidateTrackers, metadataPreview, logger) if err != nil { @@ -153,14 +287,13 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co if err != nil { return err } - var approved, ignoreDupesFor, ruleOverrides []string - if req.Options.Debug || req.Options.DryRun { + var approved, ignoreDupesFor []string + if currentOpts.Debug { resultByTracker := mapDupeResultsByTracker(dupeSummary) printUnattendedDupeReviewSummary(resultByTracker, candidateTrackers) - approved = debugDryRunApprovedTrackers(resultByTracker, candidateTrackers) - ignoreDupesFor = appendTrackerRemovals(nil, approved...) + approved = debugApprovedTrackers(resultByTracker, candidateTrackers) } else { - approved, ignoreDupesFor, ruleOverrides, err = promptTrackerDupeReview(reader, dupeSummary, req, candidateTrackers, nil) + approved, ignoreDupesFor, err = promptTrackerDupeReview(reader, dupeSummary, req, candidateTrackers, nil) if err != nil { return err } @@ -173,13 +306,14 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co req.Trackers = approved req.TrackersRemove = appendTrackerRemovals(req.TrackersRemove, unselectedTrackers(candidateTrackers, approved)...) req.IgnoreDupesFor = ignoreDupesFor - req.IgnoreTrackerRuleFailuresFor = ruleOverrides if req.DoubleDupeCheck && len(approved) > 0 { - approved, err = runDoubleDupeCheck(ctx, reader, coreSvc, req, approved) + var recheckSummary api.DupeCheckSummary + approved, recheckSummary, err = runDoubleDupeCheck(ctx, reader, coreSvc, req, approved, currentOpts.Debug) if err != nil { return err } + dupeSummary = recheckSummary req.IgnoreDupesFor = appendTrackerRemovals(req.IgnoreDupesFor, approved...) req.Trackers = approved req.TrackersRemove = appendTrackerRemovals(req.TrackersRemove, unselectedTrackers(candidateTrackers, approved)...) @@ -189,22 +323,52 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co return nil } - if req.Options.Debug || !req.Options.DryRun { - if err := runCLIScreenshotHandling(ctx, coreSvc, req); err != nil { - return err - } + if err := runCLIScreenshotHandling(ctx, coreSvc, req); err != nil { + return err } if err := prepareCLIImages(ctx, coreSvc, req, logger, false); err != nil { return err } + if currentOpts.Debug { + preview, err := runAcceptedCLIDebugDryRun(ctx, coreSvc, req, metadataPreview.Release, dupeSummary, true) + if err != nil { + return wrapUpbrrError(err) + } + review := cliReviewFromDryRunPreview(preview) + questionnaireAnswers, questionnaireChanged, err := promptTrackerQuestionnaires(reader, review, currentOpts) + if err != nil { + return err + } + if questionnaireChanged || len(questionnaireAnswers) > 0 { + req.TrackerQuestionnaireAnswers = questionnaireAnswers + } + if questionnaireChanged { + preview, err = runAcceptedCLIDebugDryRun(ctx, coreSvc, req, metadataPreview.Release, dupeSummary, true) + if err != nil { + return wrapUpbrrError(err) + } + review = cliReviewFromDryRunPreview(preview) + } + req, err = maybeEditCLIDescriptions(ctx, coreSvc, reader, req, review, currentOpts) + if err != nil { + return err + } + preview, err = runAcceptedCLIDebugDryRun(ctx, coreSvc, req, metadataPreview.Release, dupeSummary, false) + if err != nil { + return wrapUpbrrError(err) + } + printDebugTrackerDryRun(preview) + return nil + } + review, err := coreSvc.BuildUploadReview(ctx, req) if err != nil { return fmt.Errorf("upbrr: %w", err) } // Apply questionnaire answers before description editing and upload. When - // answers change, rebuild the review so printed dry-run/debug state matches + // answers change, rebuild the review so printed tracker-preview state matches // the request that will be uploaded. questionnaireAnswers, questionnaireChanged, err := promptTrackerQuestionnaires(reader, review, currentOpts) if err != nil { @@ -220,7 +384,7 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co } } - req, review, err = maybeEditCLIDescriptions(ctx, coreSvc, reader, req, review, currentOpts) + req, err = maybeEditCLIDescriptions(ctx, coreSvc, reader, req, review, currentOpts) if err != nil { return err } @@ -230,19 +394,78 @@ func runInteractiveCLIPathWithInputAndLogger(ctx context.Context, coreSvc api.Co req.TrackerQuestionnaireAnswers = questionnaireAnswers } - if req.Options.Debug { - printDebugUploadReview(review) - _, err = coreSvc.RunUploadPrepared(ctx, req) - return wrapUpbrrError(err) + _, err = coreSvc.RunUploadPrepared(ctx, req) + return wrapUpbrrError(err) +} + +func runAcceptedCLIDebugDryRun( + ctx context.Context, + coreSvc cliAcceptedTrackerDryRunRunner, + req api.Request, + release api.ReleaseRef, + dupeSummary api.DupeCheckSummary, + previewOnly bool, +) (api.TrackerDryRunPreview, error) { + input := api.TrackerDryRunInput{ + Release: release, + Trackers: append([]string(nil), req.Trackers...), + IgnoreDupesFor: append([]string(nil), req.IgnoreDupesFor...), + QuestionnaireAnswers: cloneCLIQuestionnaireAnswers(req.TrackerQuestionnaireAnswers), + DescriptionGroups: api.CloneDescriptionBuilderGroups(req.DescriptionGroups), + TrackerIDOverrides: maps.Clone(req.TrackerIDOverrides), + TrackerConfigOverrides: req.TrackerConfigOverrides, + TrackerSiteOverrides: req.TrackerSiteOverrides, + ClientOverrides: req.ClientOverrides, + ImageHostOverrides: req.ImageHostOverrides, + ScreenshotOverrides: req.ScreenshotOverrides, + TorrentOverrides: req.TorrentOverrides, + Options: req.Options, + } + if previewOnly { + input.Options.NoSeed = true + } + preview, err := coreSvc.RunAcceptedTrackerDryRun(ctx, api.TrackerDryRunPlan{ + Input: input, + Duplicate: api.NewAcceptedDuplicateEvidence(release, input.Trackers, dupeSummary), + }) + if err != nil { + return api.TrackerDryRunPreview{}, fmt.Errorf("run accepted tracker debug: %w", err) + } + return preview, nil +} + +func cloneCLIQuestionnaireAnswers(source map[string]map[string]string) map[string]map[string]string { + if source == nil { + return nil } - if req.Options.DryRun { - printDryRunUploadReview(review, req) - _, err = coreSvc.RunUploadPrepared(ctx, req) - return wrapUpbrrError(err) + cloned := make(map[string]map[string]string, len(source)) + for tracker, answers := range source { + cloned[tracker] = maps.Clone(answers) } + return cloned +} - _, err = coreSvc.RunUploadPrepared(ctx, req) - return wrapUpbrrError(err) +func cliReviewFromDryRunPreview(preview api.TrackerDryRunPreview) api.UploadReview { + review := api.UploadReview{SourcePath: preview.SourcePath, Trackers: make([]api.TrackerReview, 0, len(preview.Trackers))} + for _, entry := range preview.Trackers { + ruleFailures := make([]api.RuleFailure, 0, len(entry.Diagnostics.RuleDecisions)) + for _, decision := range entry.Diagnostics.RuleDecisions { + ruleFailures = append(ruleFailures, api.RuleFailure{ + Rule: decision.Rule, + Reason: decision.Reason, + Disposition: decision.Disposition, + }) + } + review.Trackers = append(review.Trackers, api.TrackerReview{ + Tracker: entry.Tracker, + Banned: entry.Banned, + BannedReason: entry.BannedReason, + RuleFailures: ruleFailures, + DryRun: entry, + Questionnaire: entry.Questionnaire, + }) + } + return review } func resolvedCLIMetadataSourcePath(input string, preview api.MetadataPreview) string { @@ -252,15 +475,22 @@ func resolvedCLIMetadataSourcePath(input string, preview api.MetadataPreview) st return input } -func resolveCLIUploadTrackers(visited map[string]bool, req api.Request, preview api.MetadataPreview, cfg config.Config) ([]string, []string) { +func resolveCLIUploadTrackers( + visited map[string]bool, + req api.Request, + preview api.MetadataPreview, + cfg config.Config, + registry *trackers.Registry, + debug bool, +) ([]string, []string) { remove := append([]string{}, req.TrackersRemove...) - if !req.Options.Debug && !req.Options.DryRun { + if !debug { remove = append(remove, matchedPreviewTrackers(preview)...) } - removalBase := trackers.ResolveTrackersWithDefaults(cfg, req.Trackers, remove, api.NopLogger{}) + removalBase := trackers.ResolveTrackersWithDefaultsAndRegistry(cfg, req.Trackers, remove, api.NopLogger{}, registry) available := removalBase if visited["trackers"] || req.Execution.SiteUploadTracker != "" { - available = trackers.ResolveTrackers(cfg, req.Trackers, remove, api.NopLogger{}) + available = trackers.ResolveTrackersWithRegistry(cfg, req.Trackers, remove, api.NopLogger{}, registry) } return available, removalBase } @@ -283,22 +513,58 @@ func trackerRuleFailuresForPreview(preview api.MetadataPreview, tracker string) return nil } -// cliTrackerRuleFailuresIgnored reports whether a preview rule failure should -// be bypassed for a tracker, using the global flag or a per-tracker override. -func cliTrackerRuleFailuresIgnored(req api.Request, tracker string) bool { - if req.IgnoreTrackerRuleFailures { - return true - } - name := strings.ToUpper(strings.TrimSpace(tracker)) - if name == "" { - return false - } - for _, allowed := range req.IgnoreTrackerRuleFailuresFor { - if strings.EqualFold(strings.TrimSpace(allowed), name) { - return true +// promptTrackerRuleReview applies strict rule blocks and records exact +// authorizations for current waivable failures before auth and duplicate work. +func promptTrackerRuleReview( + reader *bufio.Reader, + req api.Request, + trackerNames []string, + preview api.MetadataPreview, +) ([]string, []api.RuleAuthorization, error) { + approved := make([]string, 0, len(trackerNames)) + authorizations := make([]api.RuleAuthorization, 0) + for _, tracker := range trackerNames { + name := strings.ToUpper(strings.TrimSpace(tracker)) + if name == "" { + continue + } + assessment, err := trackers.AssessRuleFailures(name, trackerRuleFailuresForPreview(preview, name), nil) + if err != nil { + return nil, nil, fmt.Errorf("upbrr: assess %s rules: %w", name, err) + } + if len(assessment.Failures) == 0 { + approved = append(approved, name) + continue } + fmt.Printf("\n[%s Rules]\n", name) + printRuleFailures(assessment.Failures) + if len(assessment.Strict) > 0 { + fmt.Println("Strict rule failure: upload disabled for this tracker.") + continue + } + if len(assessment.Waivable) == 0 { + approved = append(approved, name) + continue + } + if isUnattendedNoConfirm(req) { + fmt.Printf("Unattended upload blocked for %s: waivable rule authorization required.\n", name) + continue + } + allow, err := promptYesNo(reader, fmt.Sprintf("Authorize current waivable rule failures for %s?", name), false) + if err != nil { + return nil, nil, err + } + if !allow { + continue + } + rules := make([]string, 0, len(assessment.Waivable)) + for _, failure := range assessment.Waivable { + rules = append(rules, failure.Rule) + } + authorizations = append(authorizations, api.RuleAuthorization{Tracker: name, Rules: rules}) + approved = append(approved, name) } - return false + return approved, authorizations, nil } // removeUnreadyCLIAuthTrackers keeps only trackers marked ready to continue @@ -331,7 +597,15 @@ func removeUnreadyCLIAuthTrackers(candidateTrackers []string, readyTrackers []st // ensureCLITrackerAuthBeforeDupeCheckWithLogger validates tracker auth using a // service that shares the CLI logger for service-level and CLI decision logs. -func ensureCLITrackerAuthBeforeDupeCheckWithLogger(ctx context.Context, reader *bufio.Reader, cfg config.Config, req api.Request, trackerNames []string, preview api.MetadataPreview, logger api.Logger) ([]string, error) { +func ensureCLITrackerAuthBeforeDupeCheckWithLogger( + ctx context.Context, + reader *bufio.Reader, + cfg config.Config, + req api.Request, + trackerNames []string, + preview api.MetadataPreview, + logger api.Logger, +) ([]string, error) { if len(trackerNames) == 0 { return trackerNames, nil } @@ -341,16 +615,29 @@ func ensureCLITrackerAuthBeforeDupeCheckWithLogger(ctx context.Context, reader * // ensureCLITrackerAuthBeforeDupeCheckWithService is the injectable form of // ensureCLITrackerAuthBeforeDupeCheck used by tests. -func ensureCLITrackerAuthBeforeDupeCheckWithService(ctx context.Context, reader *bufio.Reader, authSvc cliTrackerAuthService, req api.Request, trackerNames []string) ([]string, error) { +func ensureCLITrackerAuthBeforeDupeCheckWithService( + ctx context.Context, + reader *bufio.Reader, + authSvc cliTrackerAuthService, + req api.Request, + trackerNames []string, +) ([]string, error) { return ensureCLITrackerAuthBeforeDupeCheckWithServiceAndLogger(ctx, reader, authSvc, req, trackerNames, api.MetadataPreview{}, api.NopLogger{}) } // ensureCLITrackerAuthBeforeDupeCheckWithServiceAndLogger applies CLI-specific // keep/skip behavior and logs redacted outcomes for managed-auth trackers. -// Preview rule failures suppress managed-auth checks only after capability -// classification and leave those managed trackers out of the ready result, so -// static API-key/passkey trackers stay quiet here. -func ensureCLITrackerAuthBeforeDupeCheckWithServiceAndLogger(ctx context.Context, reader *bufio.Reader, authSvc cliTrackerAuthService, req api.Request, trackerNames []string, preview api.MetadataPreview, logger api.Logger) ([]string, error) { +// Rule findings do not suppress auth diagnostics; static API-key/passkey +// trackers remain outside managed-auth validation. +func ensureCLITrackerAuthBeforeDupeCheckWithServiceAndLogger( + ctx context.Context, + reader *bufio.Reader, + authSvc cliTrackerAuthService, + req api.Request, + trackerNames []string, + _ api.MetadataPreview, + logger api.Logger, +) ([]string, error) { logger = cliAuthLogger(logger) capabilities, err := authSvc.Capabilities(ctx) @@ -383,10 +670,6 @@ func ensureCLITrackerAuthBeforeDupeCheckWithServiceAndLogger(ctx context.Context readyByTracker[name] = struct{}{} continue } - if !cliTrackerRuleFailuresIgnored(req, name) && api.HasBlockingRuleFailures(trackerRuleFailuresForPreview(preview, name)) { - logger.Debugf("cli auth: tracker=%s skipped before auth due to rule failure", name) - continue - } authCheckTrackers = append(authCheckTrackers, name) } @@ -444,7 +727,15 @@ func cliTrackerAuthApplies(capability api.TrackerAuthCapability) bool { // handleCLITrackerAuthStatusWithLogger converts one auth status into a CLI // decision and logs blocked, prompt, and skip outcomes without secret material. -func handleCLITrackerAuthStatusWithLogger(ctx context.Context, reader *bufio.Reader, authSvc cliTrackerAuthService, capability api.TrackerAuthCapability, status api.TrackerAuthStatus, req api.Request, logger api.Logger) (api.TrackerAuthStatus, bool, error) { +func handleCLITrackerAuthStatusWithLogger( + ctx context.Context, + reader *bufio.Reader, + authSvc cliTrackerAuthService, + capability api.TrackerAuthCapability, + status api.TrackerAuthStatus, + req api.Request, + logger api.Logger, +) (api.TrackerAuthStatus, bool, error) { logger = cliAuthLogger(logger) if cliTrackerAuthReady(status) { return status, true, nil @@ -457,7 +748,10 @@ func handleCLITrackerAuthStatusWithLogger(ctx context.Context, reader *bufio.Rea if status.Needs2FA && strings.TrimSpace(status.ChallengeID) != "" { if isUnattendedNoConfirm(req) { logger.Warnf("cli auth: tracker=%s decision=blocked reason=2fa_required unattended=true", trackerID) - return status, false, fmt.Errorf("upbrr: unattended no-prompt tracker auth %s requires manual 2FA code before dupe check; run without --unattended or use --unattended_confirm to enter 2FA", trackerID) + return status, false, fmt.Errorf( + "upbrr: unattended no-prompt tracker auth %s requires manual 2FA code before dupe check; run without --unattended or use --unattended_confirm to enter 2FA", + trackerID, + ) } logger.Infof("cli auth: tracker=%s decision=prompt_2fa", trackerID) return promptCLITrackerAuth2FAWithLogger(ctx, reader, authSvc, trackerID, status, logger) @@ -474,7 +768,14 @@ func handleCLITrackerAuthStatusWithLogger(ctx context.Context, reader *bufio.Rea // promptCLITrackerAuth2FAWithLogger prompts for manual 2FA and logs only the // submitted outcome; it never logs codes or challenge identifiers. -func promptCLITrackerAuth2FAWithLogger(ctx context.Context, reader *bufio.Reader, authSvc cliTrackerAuthService, trackerID string, status api.TrackerAuthStatus, logger api.Logger) (api.TrackerAuthStatus, bool, error) { +func promptCLITrackerAuth2FAWithLogger( + ctx context.Context, + reader *bufio.Reader, + authSvc cliTrackerAuthService, + trackerID string, + status api.TrackerAuthStatus, + logger api.Logger, +) (api.TrackerAuthStatus, bool, error) { logger = cliAuthLogger(logger) for { fmt.Printf("\n[%s Auth]\n%s\n", trackerID, cliTrackerAuthStatusMessage(status)) @@ -574,7 +875,7 @@ func cliTrackerAuthReady(status api.TrackerAuthStatus) bool { // cliTrackerAuthStatusMessage renders the tracker auth status display contract: // Message is the stable summary and LastError is redacted detail displayed when -// distinct, matching GUI/web auth cards. +// distinct, matching WebUI auth cards. func cliTrackerAuthStatusMessage(status api.TrackerAuthStatus) string { message := cliTrackerAuthDisplayField(status.Message) detail := cliTrackerAuthDisplayField(status.LastError) @@ -673,23 +974,7 @@ func appendTrackerRemovals(existing []string, extra ...string) []string { return merged } -func runCLIDupeCheck(ctx context.Context, coreSvc api.Core, req api.Request) (api.DupeCheckSummary, error) { - if req.SkipDupeCheck { - results := make([]api.DupeCheckResult, 0, len(req.Trackers)) - for _, tracker := range req.Trackers { - name := strings.ToUpper(strings.TrimSpace(tracker)) - if name == "" { - continue - } - results = append(results, api.DupeCheckResult{ - Tracker: name, - Skipped: true, - Status: "skipped", - SkipReason: "dupe check skipped", - }) - } - return api.DupeCheckSummary{Results: results}, nil - } +func runCLIDupeCheck(ctx context.Context, coreSvc cliDupeChecker, req api.Request) (api.DupeCheckSummary, error) { summary, err := coreSvc.CheckDupes(ctx, req) if err != nil { return api.DupeCheckSummary{}, fmt.Errorf("upbrr: %w", err) @@ -697,14 +982,19 @@ func runCLIDupeCheck(ctx context.Context, coreSvc api.Core, req api.Request) (ap return summary, nil } -func promptTrackerDupeReview(reader *bufio.Reader, summary api.DupeCheckSummary, req api.Request, trackers []string, namePreview map[string]api.TrackerDryRunEntry) ([]string, []string, []string, error) { +func promptTrackerDupeReview( + reader *bufio.Reader, + summary api.DupeCheckSummary, + req api.Request, + trackers []string, + namePreview map[string]api.TrackerDryRunEntry, +) ([]string, []string, error) { resultByTracker := mapDupeResultsByTracker(summary) approved := make([]string, 0, len(trackers)) ignoreDupesFor := make([]string, 0) - ruleOverrides := make([]string, 0) if isUnattendedNoConfirm(req) { approved = append(approved, printUnattendedDupeReviewSummary(resultByTracker, trackers)...) - return approved, ignoreDupesFor, ruleOverrides, nil + return approved, ignoreDupesFor, nil } for _, tracker := range trackers { @@ -732,7 +1022,7 @@ func promptTrackerDupeReview(reader *bufio.Reader, summary api.DupeCheckSummary, } allow, err := promptYesNo(reader, prompt, false) if err != nil { - return nil, nil, nil, err + return nil, nil, err } if !allow { continue @@ -740,12 +1030,9 @@ func promptTrackerDupeReview(reader *bufio.Reader, summary api.DupeCheckSummary, approved = append(approved, name) if blocked { ignoreDupesFor = append(ignoreDupesFor, name) - if result.Skipped || strings.Contains(strings.ToLower(result.SkipReason), "rule") || strings.Contains(strings.ToLower(result.Error), "rule") { - ruleOverrides = append(ruleOverrides, name) - } } } - return approved, ignoreDupesFor, ruleOverrides, nil + return approved, ignoreDupesFor, nil } type unattendedDupeReviewBlock struct { @@ -800,37 +1087,20 @@ func printUnattendedDupeReviewSummary(resultByTracker map[string]api.DupeCheckRe return approved } -// debugDryRunApprovedTrackers keeps duplicate-hit trackers eligible for -// debug/dry-run processing while preserving rule failures as terminal skips. -func debugDryRunApprovedTrackers(resultByTracker map[string]api.DupeCheckResult, trackers []string) []string { +// debugApprovedTrackers keeps every selected tracker eligible for payload +// inspection without creating a duplicate override or waiver. +func debugApprovedTrackers(_ map[string]api.DupeCheckResult, trackers []string) []string { approved := make([]string, 0, len(trackers)) for _, tracker := range trackers { name := strings.ToUpper(strings.TrimSpace(tracker)) if name == "" { continue } - if result, ok := resultByTracker[name]; ok && isRuleSkippedDupeResult(result) { - continue - } approved = append(approved, name) } return approved } -// isRuleSkippedDupeResult reports whether a skipped dupe result came from -// tracker rule validation. These skips remain terminal even in debug/dry-run. -func isRuleSkippedDupeResult(result api.DupeCheckResult) bool { - if !result.Skipped { - return false - } - for _, rule := range result.SkipRules { - if strings.TrimSpace(rule) != "" { - return true - } - } - return false -} - // dupeReviewBlockReason returns the operator-facing reason for a blocked dupe // review result, preferring structured skip/error fields over display notes. func dupeReviewBlockReason(result api.DupeCheckResult) string { @@ -840,15 +1110,13 @@ func dupeReviewBlockReason(result api.DupeCheckResult) string { if reason := strings.TrimSpace(result.Error); reason != "" { return reason } - if len(result.Notes) > 0 { - for _, note := range result.Notes { - note = strings.TrimSpace(note) - if note != "" { - return strings.TrimPrefix(note, "skip: ") - } - } + if code := strings.TrimSpace(result.SkipCode); code != "" { + return "duplicate search not run (" + code + ")" } - return "skipped" + if strings.EqualFold(strings.TrimSpace(result.Status), "failed") { + return "duplicate search failed" + } + return "duplicate search not run" } // unattendedDupeSkipReasonLines groups skipped tracker names by exact reason so @@ -898,19 +1166,12 @@ func buildTrackerUploadPrompt(tracker string, blocked bool, dryRun api.TrackerDr func mapDupeResultsByTracker(summary api.DupeCheckSummary) map[string]api.DupeCheckResult { mapped := make(map[string]api.DupeCheckResult, len(summary.Results)) for _, result := range summary.Results { - trackers := splitCSV(strings.ReplaceAll(result.Tracker, ", ", ",")) - if len(trackers) == 0 { - trackers = []string{result.Tracker} - } - for _, tracker := range trackers { - name := strings.ToUpper(strings.TrimSpace(tracker)) - if name == "" { - continue - } - copyResult := result - copyResult.Tracker = name - mapped[name] = copyResult + name := strings.ToUpper(strings.TrimSpace(result.Tracker)) + if name == "" { + continue } + result.Tracker = name + mapped[name] = result } return mapped } @@ -925,30 +1186,18 @@ func dupeResultNeedsConfirmation(result api.DupeCheckResult, hasResult bool) boo if result.HasDupes || result.Skipped { return true } - if strings.EqualFold(strings.TrimSpace(result.Status), "failed") { - return true - } - return strings.TrimSpace(result.Error) != "" + return strings.EqualFold(strings.TrimSpace(result.Status), "failed") } func dupeResultSkipsPrompt(result api.DupeCheckResult) bool { - return isPathedTorrentDupeResult(result) -} - -func isPathedTorrentDupeResult(result api.DupeCheckResult) bool { - for _, note := range result.Notes { - if strings.EqualFold(strings.TrimSpace(note), "pathed torrent match found; skipping dupe search") { - return true - } - } - return false + return result.HasDupes && strings.EqualFold(strings.TrimSpace(result.Match.MatchedReason), "in_client") } func isUserRequestedDupeSkipResult(result api.DupeCheckResult) bool { - return result.Skipped && strings.EqualFold(strings.TrimSpace(result.SkipReason), "dupe check skipped") + return result.Skipped && strings.EqualFold(strings.TrimSpace(result.SkipCode), "user_requested") } -func runCLIScreenshotHandling(ctx context.Context, coreSvc api.Core, req api.Request) error { +func runCLIScreenshotHandling(ctx context.Context, coreSvc cliScreenshotCore, req api.Request) error { plan, err := coreSvc.FetchScreenshotPlan(ctx, req) if err != nil { return fmt.Errorf("upbrr: screenshot plan: %w", err) @@ -1058,17 +1307,33 @@ func formatScreenshotErrors(errorsList []api.ScreenshotError) string { return strings.Join(parts, "; ") } -func runSiteCheckCLIPath(ctx context.Context, coreSvc api.Core, opts cliOptions, visited map[string]bool, sourcePath string, screens int) error { +// runSiteCheckCLIPath adapts a plain path to the source-scoped site-check item workflow. +func runSiteCheckCLIPath(ctx context.Context, coreSvc cliSiteCheckCore, opts cliOptions, visited map[string]bool, sourcePath string, screens int) error { + return runSiteCheckCLIItem(ctx, coreSvc, opts, visited, cliPreparationItem{originalPath: sourcePath, resolvedPath: sourcePath}, screens) +} + +// runSiteCheckCLIItem prepares and reviews one source while preserving its +// playlist instruction and original display path. +func runSiteCheckCLIItem( + ctx context.Context, + coreSvc cliSiteCheckCore, + opts cliOptions, + visited map[string]bool, + item cliPreparationItem, + screens int, +) error { + sourcePath := item.originalPath req, err := buildCLIRequest(opts, visited, []string{sourcePath}, screens) if err != nil { return err } + req.PlaylistInstruction = cloneCLIPlaylistInstruction(item.playlistInstruction) preview, err := coreSvc.FetchMetadataPreview(ctx, req) if err != nil { return fmt.Errorf("upbrr: %w", err) } - req.Paths = []string{resolvedCLIMetadataSourcePath(sourcePath, preview)} + req.SourcePath = resolvedCLIMetadataSourcePath(sourcePath, preview) review, err := coreSvc.BuildUploadReview(ctx, req) if err != nil { return fmt.Errorf("upbrr: %w", err) @@ -1100,7 +1365,8 @@ func promptTrackerQuestionnaires(reader *bufio.Reader, review api.UploadReview, answers := make(map[string]map[string]string) changed := false for _, tracker := range review.Trackers { - if tracker.Banned || api.HasBlockingRuleFailures(tracker.RuleFailures) || tracker.Questionnaire == nil || len(tracker.Questionnaire.Fields) == 0 { + if tracker.Banned || api.HasBlockingRuleFailures(tracker.RuleFailures) || + tracker.Questionnaire == nil || len(tracker.Questionnaire.Fields) == 0 { continue } trackerKey := strings.ToUpper(strings.TrimSpace(tracker.Tracker)) @@ -1114,11 +1380,19 @@ func promptTrackerQuestionnaires(reader *bufio.Reader, review api.UploadReview, if opts.Unattended && !opts.UnattendedConfirm { if field.Required && defaultValue == "" { if opts.Debug { - fmt.Printf("Debug mode: %s questionnaire value missing for %s; continuing without prompt.\n", questionnaireFieldLabel(field), tracker.Tracker) + fmt.Printf( + "Debug mode: %s questionnaire value missing for %s; continuing without prompt.\n", + questionnaireFieldLabel(field), + tracker.Tracker, + ) values[field.Key] = "" continue } - return nil, false, fmt.Errorf("upbrr: unattended upload requires %s questionnaire value for %s", questionnaireFieldLabel(field), tracker.Tracker) + return nil, false, fmt.Errorf( + "upbrr: unattended upload requires %s questionnaire value for %s", + questionnaireFieldLabel(field), + tracker.Tracker, + ) } values[field.Key] = defaultValue continue @@ -1160,12 +1434,19 @@ func questionnaireFieldLabel(field api.TrackerQuestionnaireField) string { return strings.TrimSpace(field.Key) } -func runDoubleDupeCheck(ctx context.Context, reader *bufio.Reader, coreSvc api.Core, req api.Request, trackers []string) ([]string, error) { +func runDoubleDupeCheck( + ctx context.Context, + reader *bufio.Reader, + coreSvc cliDupeChecker, + req api.Request, + trackers []string, + debug bool, +) ([]string, api.DupeCheckSummary, error) { recheckReq := req recheckReq.Trackers = trackers summary, err := coreSvc.CheckDupes(ctx, recheckReq) if err != nil { - return nil, fmt.Errorf("upbrr: %w", err) + return nil, api.DupeCheckSummary{}, fmt.Errorf("upbrr: %w", err) } resultByTracker := make(map[string]api.DupeCheckResult, len(summary.Results)) @@ -1186,7 +1467,7 @@ func runDoubleDupeCheck(ctx context.Context, reader *bufio.Reader, coreSvc api.C } fmt.Printf("\nDouble dupe check flagged %s:\n", tracker) printDupeResult(result) - if req.Options.Debug { + if debug { fmt.Printf("Debug mode: keeping %s despite second dupe check.\n", tracker) filtered = append(filtered, tracker) continue @@ -1197,13 +1478,13 @@ func runDoubleDupeCheck(ctx context.Context, reader *bufio.Reader, coreSvc api.C } upload, err := promptYesNo(reader, fmt.Sprintf("Upload to %s anyway after second dupe check? [y/N]: ", tracker), false) if err != nil { - return nil, err + return nil, api.DupeCheckSummary{}, err } if upload { filtered = append(filtered, tracker) } } - return filtered, nil + return filtered, summary, nil } func buildQuestionnairePrompt(field api.TrackerQuestionnaireField) string { @@ -1241,55 +1522,59 @@ func printMetadataPreview(preview api.MetadataPreview, debug bool) { if preview.TrackerName != "" { fmt.Printf("Tracker data from: %s\n", preview.TrackerName) } - printMetadataExternalIDs(preview) - if len(preview.ExternalIDCandidates.TMDB) > 0 || len(preview.ExternalIDCandidates.IMDB) > 0 { + printMetadataExternalIdentity(preview) + if metadataPreviewHasCandidates(preview.Diagnostics) { fmt.Println("Candidate IDs available; use override args if needed.") } - if len(preview.Warnings) > 0 { + warnings := make([]string, 0) + for _, diagnostic := range preview.Diagnostics { + if diagnostic.Severity == api.DiagnosticSeverityWarning && strings.TrimSpace(diagnostic.Message) != "" { + warnings = append(warnings, diagnostic.Message) + } + } + if len(warnings) > 0 { fmt.Println("Warnings:") - for _, warning := range preview.Warnings { + for _, warning := range warnings { fmt.Printf("- %s\n", warning) } } } -func printMetadataDatabaseInfo(external api.ExternalPreview, preview api.MetadataPreview) { +func printMetadataDatabaseInfo(external api.ProviderDisplay, _ api.MetadataPreview) { fmt.Println() fmt.Println("Database info") - title := strings.TrimSpace(external.Title) - if title == "" { - title = strings.TrimSpace(preview.ReleaseName) - } - if title != "" && external.Year != 0 { - fmt.Printf("Title: %s (%d)\n", title, external.Year) - } else if title != "" { - fmt.Printf("Title: %s\n", title) + summary := external.Summary + if summary.Title != "" && summary.Year != 0 { + fmt.Printf("Title: %s (%d)\n", summary.Title, summary.Year) + } else if summary.Title != "" { + fmt.Printf("Title: %s\n", summary.Title) } - if overview := summarizeMetadataText(external.Overview, 260); overview != "" { + if overview := summarizeMetadataText(summary.Overview, 260); overview != "" { fmt.Printf("Overview: %s\n", overview) } - if genres := strings.TrimSpace(external.Genres); genres != "" { + if genres := strings.TrimSpace(summary.Genres); genres != "" { fmt.Printf("Genres: %s\n", genres) } - if category := metadataPreviewCategory(external, preview); category != "" { - fmt.Printf("Category: %s\n", category) + if summary.Category != "" { + fmt.Printf("Category: %s\n", strings.ToUpper(summary.Category)) } - if date := metadataPreviewDate(external); date != "" { - fmt.Printf("Date: %s\n", date) + if summary.Date != "" { + fmt.Printf("Date: %s\n", summary.Date) } - if runtime := metadataPreviewRuntime(external); runtime != "" { - fmt.Printf("Runtime: %s\n", runtime) + if summary.RuntimeMinutes != 0 { + fmt.Printf("Runtime: %d min\n", summary.RuntimeMinutes) } - if external.Rating != 0 { - if external.RatingCount != 0 { - fmt.Printf("Rating: %.1f (%d votes)\n", external.Rating, external.RatingCount) + if summary.Rating != 0 { + if summary.RatingCount != 0 { + fmt.Printf("Rating: %.1f (%d votes)\n", summary.Rating, summary.RatingCount) } else { - fmt.Printf("Rating: %.1f\n", external.Rating) + fmt.Printf("Rating: %.1f\n", summary.Rating) } } } -func printMetadataExternalIDs(preview api.MetadataPreview) { +func printMetadataExternalIdentity(preview api.MetadataPreview) { + identity := preview.Identity printedHeader := false printHeader := func() { if printedHeader { @@ -1299,117 +1584,44 @@ func printMetadataExternalIDs(preview api.MetadataPreview) { fmt.Println("External IDs") printedHeader = true } - if preview.ExternalIDs.TMDBID != 0 { + if identity.TMDBID != 0 { printHeader() - fmt.Printf("TMDB: %s\n", tmdbURL(preview.ExternalIDs.TMDBID, metadataPreviewTMDBCategory(preview))) + fmt.Printf("TMDB: %d\n", identity.TMDBID) } - if preview.ExternalIDs.IMDBID != 0 { + if identity.IMDBID != 0 { printHeader() - fmt.Printf("IMDb: https://www.imdb.com/title/tt%07d\n", preview.ExternalIDs.IMDBID) + fmt.Printf("IMDb: tt%07d\n", identity.IMDBID) } - if preview.ExternalIDs.TVDBID != 0 { + if identity.TVDBID != 0 { printHeader() - fmt.Printf("TVDB: https://www.thetvdb.com/?id=%d&tab=series\n", preview.ExternalIDs.TVDBID) + fmt.Printf("TVDB: %d\n", identity.TVDBID) } - if preview.ExternalIDs.TVmazeID != 0 { + if identity.TVmazeID != 0 { printHeader() - fmt.Printf("TVmaze: https://www.tvmaze.com/shows/%d\n", preview.ExternalIDs.TVmazeID) + fmt.Printf("TVmaze: %d\n", identity.TVmazeID) } - if preview.ExternalIDs.MALID != 0 { + if identity.MALID != 0 { printHeader() - fmt.Printf("MAL: https://myanimelist.net/anime/%d\n", preview.ExternalIDs.MALID) + fmt.Printf("MAL: %d\n", identity.MALID) } } -func primaryMetadataPreview(preview api.MetadataPreview) *api.ExternalPreview { - preferred := []string{"tmdb", "tvdb", "imdb", "tvmaze"} - for _, provider := range preferred { - for i := range preview.ExternalPreview { - external := &preview.ExternalPreview[i] - if strings.EqualFold(strings.TrimSpace(external.Provider), provider) && metadataPreviewHasDetails(*external) { - return external - } - } - } - for i := range preview.ExternalPreview { - if metadataPreviewHasDetails(preview.ExternalPreview[i]) { - return &preview.ExternalPreview[i] +func primaryMetadataPreview(preview api.MetadataPreview) *api.ProviderDisplay { + for index := range preview.Display.Providers { + if preview.Display.Providers[index].SummaryAvailable { + return &preview.Display.Providers[index] } } return nil } -func metadataPreviewHasDetails(external api.ExternalPreview) bool { - return strings.TrimSpace(external.Title) != "" || - strings.TrimSpace(external.Overview) != "" || - strings.TrimSpace(external.Genres) != "" || - external.Year != 0 || - external.Rating != 0 || - external.Runtime != 0 || - external.RuntimeMinutes != 0 -} - -func metadataPreviewCategory(external api.ExternalPreview, preview api.MetadataPreview) string { - if category := strings.TrimSpace(external.Category); category != "" { - return strings.ToUpper(category) - } - if category := strings.TrimSpace(preview.ExternalIDs.Category); category != "" { - return strings.ToUpper(category) - } - if tmdbType := strings.TrimSpace(external.TMDBType); tmdbType != "" { - return strings.ToUpper(tmdbType) - } - if imdbType := strings.TrimSpace(external.IMDBType); imdbType != "" { - return strings.ToUpper(imdbType) - } - return "" -} - -func metadataPreviewDate(external api.ExternalPreview) string { - for _, value := range []string{external.ReleaseDate, external.FirstAirDate, external.Premiered} { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func metadataPreviewRuntime(external api.ExternalPreview) string { - runtime := external.Runtime - if runtime == 0 { - runtime = external.RuntimeMinutes - } - if runtime == 0 { - return "" - } - return fmt.Sprintf("%d min", runtime) -} - -func metadataPreviewTMDBCategory(preview api.MetadataPreview) string { - for _, external := range preview.ExternalPreview { - if !strings.EqualFold(strings.TrimSpace(external.Provider), "tmdb") { - continue - } - if category := strings.TrimSpace(external.Category); category != "" { - return category - } - if tmdbType := strings.TrimSpace(external.TMDBType); tmdbType != "" { - return tmdbType +func metadataPreviewHasCandidates(diagnostics []api.PreparationDiagnostic) bool { + for _, diagnostic := range diagnostics { + if len(diagnostic.Candidates) > 0 { + return true } } - if category := strings.TrimSpace(preview.ExternalIDs.Category); category != "" { - return category - } - return "movie" -} - -func tmdbURL(id int, category string) string { - switch strings.ToLower(strings.TrimSpace(category)) { - case "tv", "series", "show": - return fmt.Sprintf("https://www.themoviedb.org/tv/%d", id) - default: - return fmt.Sprintf("https://www.themoviedb.org/movie/%d", id) - } + return false } func summarizeMetadataText(value string, limit int) string { @@ -1425,9 +1637,13 @@ func summarizeMetadataText(value string, limit int) string { } func printDupeResult(result api.DupeCheckResult) { - fmt.Printf("Dupe check status: %s\n", result.Status) + writeDupeResult(os.Stdout, result) +} + +func writeDupeResult(w io.Writer, result api.DupeCheckResult) { + fmt.Fprintf(w, "Dupe check status: %s\n", result.Status) for _, note := range result.Notes { - fmt.Printf("- %s\n", note) + fmt.Fprintf(w, "- %s\n", note) } entries := result.Filtered if len(entries) == 0 { @@ -1441,7 +1657,7 @@ func printDupeResult(result api.DupeCheckResult) { if entry.Link != "" { line += " - " + entry.Link } - fmt.Printf("- %s\n", line) + fmt.Fprintf(w, "- %s\n", line) } } @@ -1470,12 +1686,16 @@ func writeDryRunSummary(w io.Writer, entry api.TrackerDryRunEntry) { if bannedCheckError := strings.TrimSpace(entry.BannedCheckError); bannedCheckError != "" { fmt.Fprintf(w, "Banned group check: %s\n", bannedCheckError) } + writeDryRunDiagnostics(w, entry.Diagnostics) if change := trackerReleaseNameChangeLine(entry); change != "" { fmt.Fprintf(w, "Tracker %s\n", change) } else if entry.ReleaseName != "" { fmt.Fprintf(w, "Tracker release name: %s\n", entry.ReleaseName) } - if imageMessage := strings.TrimSpace(entry.ImageHost.Message); imageMessage != "" && (entry.ImageHost.Reuploaded || strings.EqualFold(entry.ImageHost.Status, "warning")) { + if imageMessage := strings.TrimSpace( + entry.ImageHost.Message, + ); imageMessage != "" && + (entry.ImageHost.Reuploaded || strings.EqualFold(entry.ImageHost.Status, "warning")) { fmt.Fprintf(w, "Images: %s\n", imageMessage) } for _, warning := range entry.ImageHost.Warnings { @@ -1496,6 +1716,27 @@ func writeDryRunSummary(w io.Writer, entry api.TrackerDryRunEntry) { } } +func writeDryRunDiagnostics(w io.Writer, diagnostics api.TrackerDryRunDiagnostics) { + for _, decision := range diagnostics.RuleDecisions { + disposition := api.NormalizeRuleDisposition(decision.Disposition) + fmt.Fprintf(w, "Rule [%s] %s: %s", disposition, decision.Rule, decision.Reason) + if decision.Authorized { + fmt.Fprint(w, " (authorized)") + } + fmt.Fprintln(w) + } + dupe := diagnostics.Duplicate + if strings.TrimSpace(dupe.Tracker) != "" || strings.TrimSpace(dupe.Status) != "" || dupe.HasDupes || dupe.Skipped || strings.TrimSpace(dupe.Error) != "" { + writeDupeResult(w, dupe) + } + if len(diagnostics.LiveEligibilityReasons) > 0 { + fmt.Fprintln(w, "Live upload blockers:") + for _, reason := range diagnostics.LiveEligibilityReasons { + fmt.Fprintf(w, "- %s: %s\n", reason.Code, reason.Message) + } + } +} + func printDebugUploadReview(review api.UploadReview) { fmt.Printf("\n[Debug Dry Run] %s\n", formatPathLabel(review.SourcePath)) for _, group := range groupDebugPayloads(review.Trackers) { @@ -1504,6 +1745,15 @@ func printDebugUploadReview(review api.UploadReview) { } } +// printDebugTrackerDryRun renders the shared dry-run result with CLI debug labels. +func printDebugTrackerDryRun(preview api.TrackerDryRunPreview) { + review := api.UploadReview{SourcePath: preview.SourcePath, Trackers: make([]api.TrackerReview, 0, len(preview.Trackers))} + for _, entry := range preview.Trackers { + review.Trackers = append(review.Trackers, api.TrackerReview{Tracker: entry.Tracker, DryRun: entry}) + } + printDebugUploadReview(review) +} + // debugPayloadGroup represents one rendered debug payload body and the trackers // that share it. type debugPayloadGroup struct { @@ -1554,35 +1804,36 @@ func debugPayloadTrackerLabel(tracker api.TrackerReview) string { return label } -func printDryRunUploadReview(review api.UploadReview, req api.Request) { - fmt.Printf("\n[Dry Run] %s\n", formatPathLabel(review.SourcePath)) - for _, tracker := range review.Trackers { - fmt.Printf("\n[%s]\n", tracker.Tracker) - if tracker.Banned && !tracker.DryRun.Banned { - fmt.Printf("Banned group: %s\n", tracker.BannedReason) - } - printRuleFailures(tracker.RuleFailures) - if !req.SkipDupeCheck && tracker.DupeCheck.HasDupes { - printDupeResult(tracker.DupeCheck) +// printRuleFailures groups strict, waivable, and advisory results for CLI output. +func printRuleFailures(failures []api.RuleFailure) { + strict := make([]api.RuleFailure, 0) + waivable := make([]api.RuleFailure, 0) + for _, failure := range failures { + switch api.NormalizeRuleDisposition(failure.Disposition) { + case api.RuleDispositionStrict: + strict = append(strict, failure) + case api.RuleDispositionWaivable: + waivable = append(waivable, failure) + case api.RuleDispositionAdvisory: + } + } + if len(strict) > 0 { + fmt.Println("Strict rule failures:") + for _, failure := range strict { + fmt.Printf("- %s: %s\n", failure.Rule, failure.Reason) } - printDryRunSummary(tracker.DryRun) } -} - -// printRuleFailures groups blocking results and advisory warnings for CLI output. -func printRuleFailures(failures []api.RuleFailure) { - blocking := api.BlockingRuleFailures(failures) - if len(blocking) > 0 { - fmt.Println("Rule failures:") - for _, failure := range blocking { + if len(waivable) > 0 { + fmt.Println("Waivable rule failures:") + for _, failure := range waivable { fmt.Printf("- %s: %s\n", failure.Rule, failure.Reason) } } - warnings := api.WarningRuleFailures(failures) - if len(warnings) > 0 { - fmt.Println("Rule warnings:") - for _, warning := range warnings { - fmt.Printf("- %s: %s\n", warning.Rule, warning.Reason) + advisories := api.AdvisoryRuleFailures(failures) + if len(advisories) > 0 { + fmt.Println("Rule advisories:") + for _, advisory := range advisories { + fmt.Printf("- %s: %s\n", advisory.Rule, advisory.Reason) } } } @@ -1747,7 +1998,19 @@ func payloadIncludesDescription(payload map[string]string) bool { func isDryRunBodyPayloadField(key string) bool { switch normalizedDryRunPayloadKey(key) { - case "description", "desc", "descr", "release_desc", "album_desc", "mediainfo", "mediainfo[]", "media_info", "bdinfo", "bd_info", "techinfo", "technical_info", "technicaldetails": + case "description", + "desc", + "descr", + "release_desc", + "album_desc", + "mediainfo", + "mediainfo[]", + "media_info", + "bdinfo", + "bd_info", + "techinfo", + "technical_info", + "technicaldetails": return true default: return false diff --git a/cmd/upbrr/interactive_test.go b/cmd/upbrr/interactive_test.go index 901b74a4..d5616c7c 100644 --- a/cmd/upbrr/interactive_test.go +++ b/cmd/upbrr/interactive_test.go @@ -11,18 +11,31 @@ import ( "maps" "os" "path/filepath" + "slices" "strings" "testing" "time" "github.com/autobrr/upbrr/internal/config" internalerrors "github.com/autobrr/upbrr/internal/errors" - "github.com/autobrr/upbrr/internal/trackerauth" + "github.com/autobrr/upbrr/internal/sourcelayout" + "github.com/autobrr/upbrr/internal/trackers" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) var errSyntheticDVDMenuCapture = errors.New("synthetic DVD menu capture failure") +func newCLITrackerRegistryForTest(t *testing.T) *trackers.Registry { + t.Helper() + registry, err := trackerimpl.NewRegistry() + if err != nil { + t.Fatalf("create tracker registry: %v", err) + } + return registry +} + func assertDVDMenuCaptureError(t *testing.T, err error) { t.Helper() @@ -65,7 +78,11 @@ func TestRunInteractiveCLIPathHandlesScreenshotsBeforeReview(t *testing.T) { SuggestedSelections: []api.ScreenshotSelection{{Index: 1, TimestampSeconds: 60}}, }, screenshotResult: api.ScreenshotResult{ - Images: []api.ScreenshotImage{{Index: 1, TimestampSeconds: 60, Path: "screen1.png"}}, + Images: []api.ScreenshotImage{{ + Index: 1, + TimestampSeconds: 60, + Path: "screen1.png", + }}, }, review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } @@ -135,7 +152,7 @@ func TestRunInteractiveCLIPathDVDMenuCaptureFailureStopsBeforeReview(t *testing. } } -func TestRunInteractiveCLIPathExplicitDryRunCapturesDVDMenus(t *testing.T) { +func TestRunInteractiveCLIPathDebugCapturesDVDMenus(t *testing.T) { t.Parallel() discRoot := t.TempDir() @@ -148,14 +165,18 @@ func TestRunInteractiveCLIPathExplicitDryRunCapturesDVDMenus(t *testing.T) { }, review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } - err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{Unattended: true, DryRun: true, GetDVDMenus: true}, map[string]bool{}, discRoot, config.Config{ + err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{ + Unattended: true, + Debug: true, + GetDVDMenus: true, + }, map[string]bool{}, discRoot, config.Config{ Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU"}}, }) if err != nil { t.Fatalf("runInteractiveCLIPath: %v", err) } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,capture-dvd-menus,review" { - t.Fatalf("dry-run capture order = %s", got) + if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,capture-dvd-menus,debug,debug" { + t.Fatalf("debug capture order = %s", got) } } @@ -207,7 +228,11 @@ func TestRunInteractiveCLIPathDoubleDupeBeforeScreenshotAndReview(t *testing.T) SuggestedSelections: []api.ScreenshotSelection{{Index: 1, TimestampSeconds: 60}}, }, screenshotResult: api.ScreenshotResult{ - Images: []api.ScreenshotImage{{Index: 1, TimestampSeconds: 60, Path: "screen1.png"}}, + Images: []api.ScreenshotImage{{ + Index: 1, + TimestampSeconds: 60, + Path: "screen1.png", + }}, }, review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } @@ -222,7 +247,7 @@ func TestRunInteractiveCLIPathDoubleDupeBeforeScreenshotAndReview(t *testing.T) } } -func TestRunInteractiveCLIPathDryRunSkipsScreenshotSideEffects(t *testing.T) { +func TestRunInteractiveCLIPathDebugRunsScreenshotSideEffects(t *testing.T) { t.Parallel() coreSvc := &cliCoreForTest{ @@ -230,38 +255,46 @@ func TestRunInteractiveCLIPathDryRunSkipsScreenshotSideEffects(t *testing.T) { SuggestedSelections: []api.ScreenshotSelection{{Index: 1, TimestampSeconds: 60}}, }, screenshotResult: api.ScreenshotResult{ - Images: []api.ScreenshotImage{{Index: 1, TimestampSeconds: 60, Path: "screen1.png"}}, + Images: []api.ScreenshotImage{{ + Index: 1, + TimestampSeconds: 60, + Path: "screen1.png", + }}, }, review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } - err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{Unattended: true, DryRun: true}, map[string]bool{}, "movie.mkv", config.Config{ + err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{Unattended: true, Debug: true}, map[string]bool{}, "movie.mkv", config.Config{ Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU"}}, }) if err != nil { t.Fatalf("runInteractiveCLIPath: %v", err) } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,review" { - t.Fatalf("expected dry-run to skip screenshot side effects, got %s", got) + if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,generate-screenshots,save-screenshots,debug,debug" { + t.Fatalf("expected debug to prepare screenshots before the dry-run module, got %s", got) } - if len(coreSvc.savedFinalImages) != 0 { - t.Fatalf("expected dry-run to skip saved screenshots, got %#v", coreSvc.savedFinalImages) + if len(coreSvc.savedFinalImages) != 1 { + t.Fatalf("expected debug to retain generated screenshots, got %#v", coreSvc.savedFinalImages) } - if coreSvc.runUploadPreparedCalls != 1 { - t.Fatalf("expected dry-run to run prepared injection path, got %d", coreSvc.runUploadPreparedCalls) + if coreSvc.runUploadPreparedCalls != 0 || coreSvc.runTrackerDryRunCalls != 2 { + t.Fatalf("expected debug dry-run only, upload=%d debug=%d", coreSvc.runUploadPreparedCalls, coreSvc.runTrackerDryRunCalls) } uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req if uploadReq.Options.NoSeed { - t.Fatalf("expected dry-run prepared upload to preserve no-seed=false, got %#v", uploadReq.Options) + t.Fatalf("expected debug request to preserve no-seed=false, got %#v", uploadReq.Options) } } -func TestRunInteractiveCLIPathDryRunPreservesExplicitNoSeed(t *testing.T) { +func TestRunInteractiveCLIPathDebugPreservesExplicitNoSeed(t *testing.T) { t.Parallel() coreSvc := &cliCoreForTest{ review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } - err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{Unattended: true, DryRun: true, NoSeed: true}, map[string]bool{}, "movie.mkv", config.Config{ + err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{ + Unattended: true, + Debug: true, + NoSeed: true, + }, map[string]bool{}, "movie.mkv", config.Config{ Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU"}}, }) if err != nil { @@ -270,7 +303,7 @@ func TestRunInteractiveCLIPathDryRunPreservesExplicitNoSeed(t *testing.T) { uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req if !uploadReq.Options.NoSeed { - t.Fatalf("expected explicit dry-run no-seed to be preserved, got %#v", uploadReq.Options) + t.Fatalf("expected explicit debug no-seed to be preserved, got %#v", uploadReq.Options) } } @@ -282,7 +315,11 @@ func TestRunInteractiveCLIPathDebugHandlesScreenshotsBeforeReview(t *testing.T) SuggestedSelections: []api.ScreenshotSelection{{Index: 1, TimestampSeconds: 60}}, }, screenshotResult: api.ScreenshotResult{ - Images: []api.ScreenshotImage{{Index: 1, TimestampSeconds: 60, Path: "screen1.png"}}, + Images: []api.ScreenshotImage{{ + Index: 1, + TimestampSeconds: 60, + Path: "screen1.png", + }}, }, review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, } @@ -292,14 +329,14 @@ func TestRunInteractiveCLIPathDebugHandlesScreenshotsBeforeReview(t *testing.T) if err != nil { t.Fatalf("runInteractiveCLIPath: %v", err) } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,generate-screenshots,save-screenshots,review" { + if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,generate-screenshots,save-screenshots,debug,debug" { t.Fatalf("expected debug to prepare screenshots before review, got %s", got) } if len(coreSvc.savedFinalImages) != 1 || coreSvc.savedFinalImages[0].Path != "screen1.png" { t.Fatalf("expected debug to save generated final screenshot, got %#v", coreSvc.savedFinalImages) } - if coreSvc.runUploadPreparedCalls != 1 { - t.Fatalf("expected debug to run prepared injection path, got %d", coreSvc.runUploadPreparedCalls) + if coreSvc.runUploadPreparedCalls != 0 || coreSvc.runTrackerDryRunCalls != 2 { + t.Fatalf("expected debug dry-run only, upload=%d debug=%d", coreSvc.runUploadPreparedCalls, coreSvc.runTrackerDryRunCalls) } uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req if uploadReq.Options.NoSeed { @@ -320,8 +357,18 @@ func TestRunInteractiveCLIPathDebugProcessesNonRuleCheckedTrackers(t *testing.T) }}, dupeSummary: api.DupeCheckSummary{ Results: []api.DupeCheckResult{ - {Tracker: "AITHER", Status: "completed", HasDupes: true}, - {Tracker: "BLU", Status: "skipped", Skipped: true, SkipRules: []string{"require_movie_only"}}, + { + Tracker: "AITHER", + Status: "completed", + HasDupes: true, + }, + { + Tracker: "BLU", + Status: "skipped", + Skipped: true, + SkipCode: "missing_metadata", + SkipReason: "missing metadata", + }, {Tracker: "DP", Status: "completed"}, }, }, @@ -337,95 +384,33 @@ func TestRunInteractiveCLIPathDebugProcessesNonRuleCheckedTrackers(t *testing.T) if err != nil { t.Fatalf("runInteractiveCLIPath: %v", err) } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,review" { - t.Fatalf("expected debug to run checks and continue to review, got %s", got) + if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,screenshot-plan,debug,debug" { + t.Fatalf("expected debug to reuse dupe evidence without upload review, got %s", got) } uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req - if got := strings.Join(uploadReq.Trackers, ","); got != "AITHER,DP" { + if got := strings.Join(uploadReq.Trackers, ","); got != "AITHER,BLU,DP" { t.Fatalf("expected debug upload to keep all trackers, got %s", got) } - if got := strings.Join(uploadReq.IgnoreDupesFor, ","); got != "AITHER,DP" { - t.Fatalf("expected debug upload to ignore dupe blocks for non-rule trackers, got %s", got) + if got := strings.Join(uploadReq.IgnoreDupesFor, ","); got != "" { + t.Fatalf("expected debug upload not to infer duplicate authorization, got %s", got) } - if got := strings.Join(uploadReq.IgnoreTrackerRuleFailuresFor, ","); got != "" { - t.Fatalf("expected debug upload not to ignore rule blocks, got %s", got) + if len(uploadReq.RuleAuthorizations) != 0 { + t.Fatalf("expected debug upload not to authorize rule blocks, got %#v", uploadReq.RuleAuthorizations) } -} - -func TestRunInteractiveCLIPathDryRunProcessesNonRuleCheckedTrackers(t *testing.T) { - t.Parallel() - - coreSvc := &cliCoreForTest{ - previewResponses: []api.MetadataPreview{{ - SourcePath: "Example.Release.2026.1080p-GRP", - TrackerData: []api.TrackerPreview{{ - Tracker: "AITHER", - Matched: true, - }}, - }}, - dupeSummary: api.DupeCheckSummary{ - Results: []api.DupeCheckResult{ - {Tracker: "AITHER", Status: "completed", HasDupes: true}, - {Tracker: "BLU", Status: "skipped", Skipped: true, SkipRules: []string{"require_movie_only"}}, - {Tracker: "DP", Status: "completed"}, - }, - }, - review: api.UploadReview{Trackers: []api.TrackerReview{ - {Tracker: "AITHER"}, - {Tracker: "BLU"}, - {Tracker: "DP"}, - }}, - } - err := runInteractiveCLIPath(context.Background(), coreSvc, cliOptions{Unattended: true, DryRun: true}, map[string]bool{}, "Example.Release.2026.1080p-GRP", config.Config{ - Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER", "BLU", "DP"}}, - }) - if err != nil { - t.Fatalf("runInteractiveCLIPath: %v", err) - } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,dupes,review" { - t.Fatalf("expected dry-run to run dupe check and continue to review, got %s", got) - } - uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req - if got := strings.Join(uploadReq.Trackers, ","); got != "AITHER,DP" { - t.Fatalf("expected dry-run upload to keep all trackers, got %s", got) - } - if got := strings.Join(uploadReq.IgnoreDupesFor, ","); got != "AITHER,DP" { - t.Fatalf("expected dry-run upload to ignore dupe blocks for non-rule trackers, got %s", got) - } - if got := strings.Join(uploadReq.IgnoreTrackerRuleFailuresFor, ","); got != "" { - t.Fatalf("expected dry-run upload not to ignore rule blocks, got %s", got) - } -} - -func TestIsRuleSkippedDupeResultUsesSkipRules(t *testing.T) { - t.Parallel() - - if !isRuleSkippedDupeResult(api.DupeCheckResult{ - Tracker: "BLU", - Status: "skipped", - Skipped: true, - SkipRules: []string{"require_movie_only"}, - }) { - t.Fatal("expected structured rule skip to remain terminal") - } - if isRuleSkippedDupeResult(api.DupeCheckResult{ - Tracker: "BLU", - Status: "skipped", - Skipped: true, - SkipReason: "rule check failed: category movie is not tv", - }) { - t.Fatal("expected text-only skip reason not to drive rule classification") + if len(coreSvc.dryRunPlans) != 2 { + t.Fatalf("accepted dry-run plans = %d, want 2", len(coreSvc.dryRunPlans)) } - if isRuleSkippedDupeResult(api.DupeCheckResult{ - Tracker: "BLU", - Status: "completed", - SkipRules: []string{"require_movie_only"}, - }) { - t.Fatal("expected non-skipped result not to be terminal") + for _, plan := range coreSvc.dryRunPlans { + if plan.Duplicate.Release != plan.Input.Release || plan.Duplicate.Release.Generation != 1 { + t.Fatalf("accepted duplicate release = %#v input=%#v", plan.Duplicate.Release, plan.Input.Release) + } + if len(plan.Duplicate.Results) != len(coreSvc.dupeSummary.Results) { + t.Fatalf("accepted duplicate results = %#v", plan.Duplicate.Results) + } } } -func TestRunInteractiveCLIPathQuestionnaireAnswerRebuildsDryRunReview(t *testing.T) { +func TestRunInteractiveCLIPathQuestionnaireAnswerRebuildsReviewBeforeDebug(t *testing.T) { coreSvc := &cliCoreForTest{ previewResponses: []api.MetadataPreview{{SourcePath: "Example.Release.2026.1080p-GRP"}}, dupeSummary: api.DupeCheckSummary{ @@ -450,9 +435,23 @@ func TestRunInteractiveCLIPathQuestionnaireAnswerRebuildsDryRunReview(t *testing }, }}}, }, + dryRunPreview: api.TrackerDryRunPreview{ + SourcePath: "Example.Release.2026.1080p-GRP", + Trackers: []api.TrackerDryRunEntry{{ + Tracker: "ANT", + Status: "ready", + ReleaseName: "Example.Release.2026.S01E01.1080p-GRP", + Questionnaire: &api.TrackerQuestionnaire{Fields: []api.TrackerQuestionnaireField{{ + Key: "type", + Label: "Type", + Value: "Feature", + Required: true, + }}}, + }}, + }, } output := captureStdout(t, func() { - err := runInteractiveCLIPathWithInput(context.Background(), coreSvc, cliOptions{DryRun: true, OnlyID: true}, map[string]bool{}, "Example.Release.2026.1080p-GRP", 1, config.Config{ + err := runInteractiveCLIPathWithInput(context.Background(), coreSvc, cliOptions{Debug: true, OnlyID: true}, map[string]bool{}, "Example.Release.2026.1080p-GRP", 1, config.Config{ Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"ANT"}}, }, strings.NewReader("y\nEpisode\n")) if err != nil { @@ -460,16 +459,20 @@ func TestRunInteractiveCLIPathQuestionnaireAnswerRebuildsDryRunReview(t *testing } }) reviewCalls := 0 + dryRunCalls := 0 for _, call := range coreSvc.callOrder { if call == "review" { reviewCalls++ } + if call == "debug" { + dryRunCalls++ + } } - if reviewCalls != 2 { - t.Fatalf("expected questionnaire answer to rebuild review, got %d review call(s): %#v", reviewCalls, coreSvc.callOrder) + if reviewCalls != 0 || dryRunCalls != 3 { + t.Fatalf("expected questionnaire answer to rebuild dry-run payloads from retained evidence, review=%d dry_run=%d calls=%#v", reviewCalls, dryRunCalls, coreSvc.callOrder) } if !strings.Contains(output, "Tracker release name: Example.Release.2026.S01E01.1080p-GRP") { - t.Fatalf("expected dry-run output to use rebuilt questionnaire-aware review, got %q", output) + t.Fatalf("expected debug output to use questionnaire-aware dry run, got %q", output) } uploadReq := coreSvc.requests[len(coreSvc.requests)-1].req if got := uploadReq.TrackerQuestionnaireAnswers["ANT"]["type"]; got != "Episode" { @@ -502,8 +505,8 @@ func TestRunInteractiveCLIPathUsesResolvedPreviewSourceForPreparedUpload(t *test if call.name == "preview" { continue } - if len(call.req.Paths) != 1 || call.req.Paths[0] != expectedPath { - t.Fatalf("expected %s to use resolved preview source %q, got %#v", call.name, expectedPath, call.req.Paths) + if call.req.SourcePath != expectedPath { + t.Fatalf("expected %s to use resolved preview source %q, got %q", call.name, expectedPath, call.req.SourcePath) } if call.req.TorrentOverrides.Rehash == nil || *call.req.TorrentOverrides.Rehash != rehash { t.Fatalf("expected %s to preserve rehash override, got %#v", call.name, call.req.TorrentOverrides.Rehash) @@ -583,7 +586,7 @@ func TestRunSiteCheckCLIPathSeedsMetadataBeforeReview(t *testing.T) { if got := strings.Join(coreSvc.callOrder, ","); got != "preview,review" { t.Fatalf("expected preview before review, got %s", got) } - if len(coreSvc.requests) != 2 || len(coreSvc.requests[1].req.Paths) != 1 || coreSvc.requests[1].req.Paths[0] != filepath.Join("folder", "movie.mkv") { + if len(coreSvc.requests) != 2 || coreSvc.requests[1].req.SourcePath != filepath.Join("folder", "movie.mkv") { t.Fatalf("expected site-check review to use resolved preview source, got %#v", coreSvc.requests) } } @@ -668,6 +671,8 @@ func TestResolveCLIUploadTrackersExplicitTrackersSuppressDefaults(t *testing.T) }, api.MetadataPreview{}, config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER", "BLU"}}}, + newCLITrackerRegistryForTest(t), + false, ) if len(selected) != 1 || selected[0] != "BLU" { t.Fatalf("expected explicit BLU selection, got %#v", selected) @@ -726,14 +731,31 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckValidatesApplicableTrackers(t *testi } } -func TestEnsureCLITrackerAuthBeforeDupeCheckLogsRuleFailureSkipOnlyForManagedAuth(t *testing.T) { +func TestEnsureCLITrackerAuthBeforeDupeCheckDoesNotCoupleRuleDiagnostics(t *testing.T) { t.Parallel() authSvc := &cliTrackerAuthForTest{ capabilities: []api.TrackerAuthCapability{ - {TrackerID: "MTV", AuthKind: "api_key_cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true, RequiresAPIKey: true}, - {TrackerID: "NBL", AuthKind: "api_key", RequiresAPIKey: true}, - {TrackerID: "PTP", AuthKind: "cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true}, + { + TrackerID: "MTV", + AuthKind: "api_key_cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + RequiresAPIKey: true, + }, + { + TrackerID: "NBL", + AuthKind: "api_key", + RequiresAPIKey: true, + }, + { + TrackerID: "PTP", + AuthKind: "cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + }, }, validateStatus: map[string]api.TrackerAuthStatus{ "PTP": {TrackerID: "PTP", State: trackerauth.StateConfigured}, @@ -748,7 +770,7 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckLogsRuleFailureSkipOnlyForManagedAut api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, []string{"MTV", "NBL", "PTP"}, api.MetadataPreview{TrackerRuleFailures: map[string][]api.RuleFailure{ - "MTV": {{Rule: "extra_check", Reason: "managed auth rule failure"}}, + "MTV": {{Rule: "source_rule", Reason: "managed auth rule failure"}}, "NBL": {{Rule: "require_tv_only", Reason: "static api key rule failure"}}, }}, logger, @@ -756,29 +778,39 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckLogsRuleFailureSkipOnlyForManagedAut if err != nil { t.Fatalf("ensureCLITrackerAuthBeforeDupeCheck: %v", err) } - if strings.Join(got, ",") != "NBL,PTP" { - t.Fatalf("expected managed rule-failed tracker removed while static tracker remains eligible, got %#v", got) + if strings.Join(got, ",") != "MTV,NBL,PTP" { + t.Fatalf("expected rule diagnostics not to remove auth candidates, got %#v", got) } - if strings.Join(authSvc.validated, ",") != "PTP" { - t.Fatalf("expected only rule-eligible managed auth tracker to validate, got %#v", authSvc.validated) + if strings.Join(authSvc.validated, ",") != "MTV,PTP" { + t.Fatalf("expected both managed auth trackers to validate, got %#v", authSvc.validated) } logs := strings.Join(append(append(append([]string{}, logger.debug...), logger.info...), logger.warn...), "\n") - if !strings.Contains(logs, "cli auth: tracker=MTV skipped before auth due to rule failure") { - t.Fatal("expected managed auth rule-failure skip log") - } - if strings.Contains(logs, "tracker=NBL skipped before auth due to rule failure") { - t.Fatal("static api-key tracker should not log auth rule-failure skip") + if strings.Contains(logs, "skipped before auth due to rule failure") { + t.Fatal("rule diagnostics must not suppress or relabel auth validation") } } -func TestEnsureCLITrackerAuthBeforeDupeCheckDryRunSkipsRuleFailedManagedAuth(t *testing.T) { +func TestEnsureCLITrackerAuthBeforeDupeCheckValidatesRuleFailedManagedAuth(t *testing.T) { t.Parallel() authSvc := &cliTrackerAuthForTest{ capabilities: []api.TrackerAuthCapability{ - {TrackerID: "MTV", AuthKind: "api_key_cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true, RequiresAPIKey: true}, - {TrackerID: "PTP", AuthKind: "cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true}, + { + TrackerID: "MTV", + AuthKind: "api_key_cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + RequiresAPIKey: true, + }, + { + TrackerID: "PTP", + AuthKind: "cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + }, }, } logger := &cliAuthRecordingLogger{} @@ -787,26 +819,26 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckDryRunSkipsRuleFailedManagedAuth(t * context.Background(), bufio.NewReader(strings.NewReader("")), authSvc, - api.Request{Options: api.UploadOptions{DryRun: true, InteractionMode: api.InteractionModeInteractive}}, + api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, []string{"MTV", "PTP"}, api.MetadataPreview{TrackerRuleFailures: map[string][]api.RuleFailure{ - "MTV": {{Rule: "extra_check", Reason: "managed auth rule failure"}}, + "MTV": {{Rule: "source_rule", Reason: "managed auth rule failure"}}, }}, logger, ) if err != nil { t.Fatalf("ensureCLITrackerAuthBeforeDupeCheck: %v", err) } - if strings.Join(got, ",") != "PTP" { - t.Fatalf("expected dry-run to skip rule-failed managed auth tracker, got %#v", got) + if strings.Join(got, ",") != "MTV,PTP" { + t.Fatalf("expected rule diagnostics not to remove managed auth tracker, got %#v", got) } - if strings.Join(authSvc.validated, ",") != "PTP" { - t.Fatalf("expected dry-run to validate only rule-eligible managed auth trackers, got %#v", authSvc.validated) + if strings.Join(authSvc.validated, ",") != "MTV,PTP" { + t.Fatalf("expected all managed auth trackers to be validated, got %#v", authSvc.validated) } logs := strings.Join(append(append(append([]string{}, logger.debug...), logger.info...), logger.warn...), "\n") - if !strings.Contains(logs, "tracker=MTV skipped before auth due to rule failure") { - t.Fatal("dry-run should skip managed auth due to rule failure") + if strings.Contains(logs, "skipped before auth due to rule failure") { + t.Fatal("rule diagnostics must not suppress auth validation") } } @@ -815,8 +847,20 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckHonorsPerTrackerRuleFailureOverride( authSvc := &cliTrackerAuthForTest{ capabilities: []api.TrackerAuthCapability{ - {TrackerID: "MTV", AuthKind: "api_key_cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true}, - {TrackerID: "PTP", AuthKind: "cookies_login_manual_2fa", SupportsCookieFile: true, SupportsLogin: true, SupportsManual2FA: true}, + { + TrackerID: "MTV", + AuthKind: "api_key_cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + }, + { + TrackerID: "PTP", + AuthKind: "cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsManual2FA: true, + }, }, } logger := &cliAuthRecordingLogger{} @@ -825,13 +869,10 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckHonorsPerTrackerRuleFailureOverride( context.Background(), bufio.NewReader(strings.NewReader("")), authSvc, - api.Request{ - Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}, - IgnoreTrackerRuleFailuresFor: []string{" mtv "}, - }, + api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, []string{"MTV", "PTP"}, api.MetadataPreview{TrackerRuleFailures: map[string][]api.RuleFailure{ - "mTv": {{Rule: "extra_check", Reason: "managed auth rule failure"}}, + "mTv": {{Rule: "source_rule", Reason: "managed auth rule failure"}}, }}, logger, ) @@ -839,7 +880,7 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckHonorsPerTrackerRuleFailureOverride( t.Fatalf("ensureCLITrackerAuthBeforeDupeCheck: %v", err) } if strings.Join(got, ",") != "MTV,PTP" { - t.Fatalf("expected per-tracker rule-failure override to keep MTV eligible, got %#v", got) + t.Fatalf("expected rule diagnostics not to suppress auth, got %#v", got) } if strings.Join(authSvc.validated, ",") != "MTV,PTP" { t.Fatalf("expected overridden managed tracker to validate, got %#v", authSvc.validated) @@ -847,7 +888,7 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckHonorsPerTrackerRuleFailureOverride( logs := strings.Join(append(append(append([]string{}, logger.debug...), logger.info...), logger.warn...), "\n") if strings.Contains(logs, "tracker=MTV skipped before auth due to rule failure") { - t.Fatal("overridden tracker should not log auth rule-failure skip") + t.Fatal("rule diagnostics should not log an auth skip") } } @@ -894,7 +935,12 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckLogsRedactedDecisions(t *testing.T) }, }, validateStatus: map[string]api.TrackerAuthStatus{ - "PTP": {TrackerID: "PTP", State: trackerauth.StateConfigured, CookieCount: 2, EncryptedStorage: true}, + "PTP": { + TrackerID: "PTP", + State: trackerauth.StateConfigured, + CookieCount: 2, + EncryptedStorage: true, + }, "HDB": { TrackerID: "HDB", State: trackerauth.StateLoginRequired, @@ -1195,12 +1241,22 @@ func TestEnsureCLITrackerAuthBeforeDupeCheckFailsUnattendedBTN2FA(t *testing.T) func TestPromptTrackerDupeReviewBuildsConfirmedTrackerList(t *testing.T) { t.Parallel() - approved, ignoreDupes, ruleOverrides, err := promptTrackerDupeReview( + approved, ignoreDupes, err := promptTrackerDupeReview( bufio.NewReader(strings.NewReader("y\nn\nn\n")), api.DupeCheckSummary{Results: []api.DupeCheckResult{ - {Tracker: "ANT", Status: "completed", HasDupes: true}, + { + Tracker: "ANT", + Status: "completed", + HasDupes: true, + }, {Tracker: "BLU", Status: "completed"}, - {Tracker: "NBL", Status: "skipped", Skipped: true, SkipReason: "rule check failed: category movie is not tv"}, + { + Tracker: "NBL", + Status: "skipped", + Skipped: true, + SkipReason: "missing metadata", + SkipCode: "missing_metadata", + }, }}, api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, []string{"ANT", "BLU", "NBL"}, @@ -1215,68 +1271,148 @@ func TestPromptTrackerDupeReviewBuildsConfirmedTrackerList(t *testing.T) { if strings.Join(ignoreDupes, ",") != "ANT" { t.Fatalf("expected dupe ignores for approved blocked trackers, got %#v", ignoreDupes) } - if len(ruleOverrides) != 0 { - t.Fatalf("expected no rule overrides for skipped rule result, got %#v", ruleOverrides) - } } -func TestPromptTrackerDupeReviewSkipsPathedTorrentMatches(t *testing.T) { +func TestPromptTrackerRuleReviewEnforcesStrictAndExactWaivableRules(t *testing.T) { t.Parallel() - - approved, ignoreDupes, ruleOverrides, err := promptTrackerDupeReview( - bufio.NewReader(strings.NewReader("y\n")), - api.DupeCheckSummary{Results: []api.DupeCheckResult{ + preview := api.MetadataPreview{TrackerRuleFailures: map[string][]api.RuleFailure{ + "STRICT": { { - Tracker: "BHD, DP", - Status: "completed", - HasDupes: true, - Notes: []string{"pathed torrent match found; skipping dupe search"}, - }, - {Tracker: "ANT", Status: "completed"}, - }}, +Rule: "container", + Reason: "container rejected", + Disposition: api.RuleDispositionWaivable, +}, + { +Rule: "resolution", + Reason: "resolution rejected", + Disposition: api.RuleDispositionStrict, +}, + }, + "WAIVE": {{ +Rule: "language", + Reason: "language rejected", + Disposition: api.RuleDispositionWaivable, +}}, + "ADVICE": {{ +Rule: "recommended_id", + Reason: "ID recommended", + Disposition: api.RuleDispositionAdvisory, +}}, + }} + var approved []string + var authorizations []api.RuleAuthorization + var err error + approved, authorizations, err = promptTrackerRuleReview( + bufio.NewReader(strings.NewReader("y\n")), api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, - []string{"BHD", "DP", "ANT"}, - nil, + []string{"STRICT", "WAIVE", "ADVICE"}, + preview, ) if err != nil { - t.Fatalf("promptTrackerDupeReview: %v", err) + t.Fatalf("prompt rule review: %v", err) } - if strings.Join(approved, ",") != "ANT" { - t.Fatalf("expected only ANT approved, got %#v", approved) + if !slices.Equal(approved, []string{"WAIVE", "ADVICE"}) { + t.Fatalf("approved = %#v", approved) } - if len(ignoreDupes) != 0 { - t.Fatalf("expected no dupe ignores for skipped pathed matches, got %#v", ignoreDupes) + if len(authorizations) != 1 || authorizations[0].Tracker != "WAIVE" || !slices.Equal(authorizations[0].Rules, []string{"language"}) { + t.Fatalf("authorizations = %#v", authorizations) + } +} + +func TestPromptTrackerRuleReviewUnattendedNeverPromptsForWaiver(t *testing.T) { + t.Parallel() + approved, authorizations, err := promptTrackerRuleReview( + bufio.NewReader(strings.NewReader("y\n")), + api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeUnattended}}, + []string{"WAIVE"}, + api.MetadataPreview{TrackerRuleFailures: map[string][]api.RuleFailure{ + "WAIVE": {{Rule: "language", Disposition: api.RuleDispositionWaivable}}, + }}, + ) + if err != nil { + t.Fatalf("prompt rule review: %v", err) + } + if len(approved) != 0 || len(authorizations) != 0 { + t.Fatalf("unattended review approved=%#v authorizations=%#v", approved, authorizations) } - if len(ruleOverrides) != 0 { - t.Fatalf("expected no rule overrides for skipped pathed matches, got %#v", ruleOverrides) +} + +func TestDebugPayloadRendersRuleAndDuplicateDiagnostics(t *testing.T) { + t.Parallel() + body := renderDebugPayloadBody(api.TrackerReview{Tracker: "EXAMPLE", DryRun: api.TrackerDryRunEntry{ + Tracker: "EXAMPLE", + Status: "ready", + Diagnostics: api.TrackerDryRunDiagnostics{ + RuleDecisions: []api.RuleDecision{ + { +Rule: "resolution", + Reason: "resolution rejected", + Disposition: api.RuleDispositionStrict, +}, + { +Rule: "container", + Reason: "container rejected", + Disposition: api.RuleDispositionWaivable, +}, + { +Rule: "recommended_id", + Reason: "ID recommended", + Disposition: api.RuleDispositionAdvisory, +}, + }, + Duplicate: api.DupeCheckResult{ + Tracker: "EXAMPLE", + Status: "completed", + HasDupes: true, + Filtered: []api.DupeEntry{{Name: "Example.Release.2026.1080p-GRP"}}, + }, + }, + }}) + for _, want := range []string{ + "Rule [strict] resolution: resolution rejected", + "Rule [waivable] container: container rejected", + "Rule [advisory] recommended_id: ID recommended", + "Dupe check status: completed", + "Example.Release.2026.1080p-GRP", + } { + if !strings.Contains(body, want) { + t.Fatalf("debug body missing %q:\n%s", want, body) + } } } -func TestPromptTrackerDupeReviewAllowsRuleCheckOverrides(t *testing.T) { +func TestPromptTrackerDupeReviewNeverOffersInClientOverride(t *testing.T) { t.Parallel() - approved, ignoreDupes, ruleOverrides, err := promptTrackerDupeReview( - bufio.NewReader(strings.NewReader("y\ny\ny\n")), + approved, ignoreDupes, err := promptTrackerDupeReview( + bufio.NewReader(strings.NewReader("y\n")), api.DupeCheckSummary{Results: []api.DupeCheckResult{ - {Tracker: "NBL", Status: "skipped", Skipped: true, SkipReason: "rule check failed: category movie is not tv"}, - {Tracker: "OTW", Status: "skipped", Skipped: true, Error: "rule failed: Genre does not match Animation or Family for OTW."}, + { + Tracker: "BHD", + Status: "completed", + HasDupes: true, + Match: api.DupeMatch{MatchedReason: "in_client"}, + }, + { + Tracker: "DP", + Status: "completed", + HasDupes: true, + Match: api.DupeMatch{MatchedReason: "in_client"}, + }, {Tracker: "ANT", Status: "completed"}, }}, api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, - []string{"NBL", "OTW", "ANT"}, + []string{"BHD", "DP", "ANT"}, nil, ) if err != nil { t.Fatalf("promptTrackerDupeReview: %v", err) } - if strings.Join(approved, ",") != "NBL,OTW,ANT" { - t.Fatalf("expected overridden rule-failed trackers approved, got %#v", approved) - } - if strings.Join(ignoreDupes, ",") != "NBL,OTW" { - t.Fatalf("expected dupe ignores for approved blocked rule violations, got %#v", ignoreDupes) + if strings.Join(approved, ",") != "ANT" { + t.Fatalf("expected only ANT approved, got %#v", approved) } - if strings.Join(ruleOverrides, ",") != "NBL,OTW" { - t.Fatalf("expected rule overrides for approved rule violations, got %#v", ruleOverrides) + if len(ignoreDupes) != 0 { + t.Fatalf("expected no dupe ignores for in-client matches, got %#v", ignoreDupes) } } @@ -1288,12 +1424,31 @@ func TestPromptTrackerDupeReviewApprovesUserSkippedDupeChecksInUnattendedMode(t Trackers: []string{"ANT", "BLU"}, Options: api.UploadOptions{InteractionMode: api.InteractionModeUnattended}, } - summary, err := runCLIDupeCheck(context.Background(), nil, req) + coreSvc := &cliCoreForTest{dupeSummary: api.DupeCheckSummary{Results: []api.DupeCheckResult{ + { + Tracker: "ANT", + Status: "skipped", + Skipped: true, + SkipCode: "user_requested", + SkipReason: "duplicate search not run by request", + }, + { + Tracker: "BLU", + Status: "skipped", + Skipped: true, + SkipCode: "user_requested", + SkipReason: "duplicate search not run by request", + }, + }}} + summary, err := runCLIDupeCheck(context.Background(), coreSvc, req) if err != nil { t.Fatalf("runCLIDupeCheck: %v", err) } + if len(coreSvc.requests) != 1 || coreSvc.requests[0].name != "dupes" || !coreSvc.requests[0].req.SkipDupeCheck { + t.Fatalf("expected SkipDupeCheck to be handled by Core, got %#v", coreSvc.requests) + } - approved, ignoreDupes, ruleOverrides, err := promptTrackerDupeReview( + approved, ignoreDupes, err := promptTrackerDupeReview( bufio.NewReader(strings.NewReader("")), summary, req, @@ -1309,9 +1464,6 @@ func TestPromptTrackerDupeReviewApprovesUserSkippedDupeChecksInUnattendedMode(t if len(ignoreDupes) != 0 { t.Fatalf("expected no dupe ignores for user-requested skip, got %#v", ignoreDupes) } - if len(ruleOverrides) != 0 { - t.Fatalf("expected no rule overrides for user-requested skip, got %#v", ruleOverrides) - } } func TestPromptTrackerDupeReviewGroupsUnattendedOutput(t *testing.T) { @@ -1334,7 +1486,8 @@ func TestPromptTrackerDupeReviewGroupsUnattendedOutput(t *testing.T) { Tracker: "ANT", Status: "skipped", Skipped: true, - SkipReason: "rule check failed: category tv is not movie", + SkipReason: "missing metadata", + SkipCode: "missing_metadata", }, { Tracker: "NBL", @@ -1366,7 +1519,7 @@ func TestPromptTrackerDupeReviewGroupsUnattendedOutput(t *testing.T) { var approved []string var err error output := captureStdout(t, func() { - approved, _, _, err = promptTrackerDupeReview( + approved, _, err = promptTrackerDupeReview( bufio.NewReader(strings.NewReader("")), summary, req, @@ -1382,7 +1535,7 @@ func TestPromptTrackerDupeReviewGroupsUnattendedOutput(t *testing.T) { } for _, expected := range []string{ "Dupe check summary:", - "Skipped: ANT (rule check failed: category tv is not movie)", + "Skipped: ANT (missing metadata)", "Skipped: NBL (missing api_key for tracker)", "Found potential dupes on: AITHER, MTV", "Trackers passed all checks: DP, OTW", @@ -1405,11 +1558,10 @@ func TestPromptTrackerDupeReviewGroupsUnattendedOutput(t *testing.T) { } } -func TestPromptTrackerDupeReviewSkipsAllRuleBlockedTrackersUnattended(t *testing.T) { +func TestPromptTrackerDupeReviewSkipsAllUnavailableTrackersUnattended(t *testing.T) { t.Parallel() - // When every selected tracker is rule-blocked (e.g. encodes missing - // MediaInfo encode settings), unattended review approves none. The caller + // When every selected tracker could not run, unattended review approves none. The caller // relies on an empty approval list to skip the release before // screenshots/torrent/upload, so this guards that early-skip path. req := api.Request{ @@ -1418,12 +1570,24 @@ func TestPromptTrackerDupeReviewSkipsAllRuleBlockedTrackersUnattended(t *testing } summary := api.DupeCheckSummary{ Results: []api.DupeCheckResult{ - {Tracker: "LST", Status: "skipped", Skipped: true, SkipReason: "rule check failed: missing MediaInfo encode settings"}, - {Tracker: "RF", Status: "skipped", Skipped: true, SkipReason: "rule check failed: missing MediaInfo encode settings"}, + { + Tracker: "LST", + Status: "skipped", + Skipped: true, + SkipReason: "missing metadata", + SkipCode: "missing_metadata", + }, + { + Tracker: "RF", + Status: "skipped", + Skipped: true, + SkipReason: "missing metadata", + SkipCode: "missing_metadata", + }, }, } - approved, ignoreDupes, ruleOverrides, err := promptTrackerDupeReview( + approved, ignoreDupes, err := promptTrackerDupeReview( bufio.NewReader(strings.NewReader("")), summary, req, @@ -1434,16 +1598,16 @@ func TestPromptTrackerDupeReviewSkipsAllRuleBlockedTrackersUnattended(t *testing t.Fatalf("promptTrackerDupeReview: %v", err) } if len(approved) != 0 { - t.Fatalf("expected no approved trackers when all are rule-blocked, got %#v", approved) + t.Fatalf("expected no approved trackers when all checks are unavailable, got %#v", approved) } - if len(ignoreDupes) != 0 || len(ruleOverrides) != 0 { - t.Fatalf("expected no overrides in unattended mode, got ignoreDupes=%#v ruleOverrides=%#v", ignoreDupes, ruleOverrides) + if len(ignoreDupes) != 0 { + t.Fatalf("expected no duplicate overrides in unattended mode, got %#v", ignoreDupes) } } func TestPromptTrackerDupeReviewShowsTrackerNamingChange(t *testing.T) { output := captureStdout(t, func() { - approved, _, _, err := promptTrackerDupeReview( + approved, _, err := promptTrackerDupeReview( bufio.NewReader(strings.NewReader("y\n")), api.DupeCheckSummary{Results: []api.DupeCheckResult{{Tracker: "AITHER", Status: "completed"}}}, api.Request{Options: api.UploadOptions{InteractionMode: api.InteractionModeInteractive}}, @@ -1470,20 +1634,15 @@ func TestPromptTrackerDupeReviewShowsTrackerNamingChange(t *testing.T) { } } -func TestPrepareCLIUploadMetadataSeedsEachPath(t *testing.T) { +func TestCLIPreparationBatchExpandsIndependentRequests(t *testing.T) { t.Parallel() - coreSvc := &cliCoreForTest{} - req := api.Request{Paths: []string{"one.mkv", "two.mkv"}} - resolvedReq, err := prepareCLIUploadMetadata(context.Background(), coreSvc, req) - if err != nil { - t.Fatalf("prepareCLIUploadMetadata: %v", err) - } - if len(coreSvc.previewPaths) != 2 || coreSvc.previewPaths[0] != "one.mkv" || coreSvc.previewPaths[1] != "two.mkv" { - t.Fatalf("unexpected preview paths: %#v", coreSvc.previewPaths) + batch := newCLIPreparationBatch(api.Request{Trackers: []string{"BLU"}}, []string{"one.mkv", "two.mkv"}) + if len(batch.items) != 2 { + t.Fatalf("expected two batch items, got %d", len(batch.items)) } - if strings.Join(resolvedReq.Paths, ",") != "one.mkv,two.mkv" { - t.Fatalf("unexpected resolved paths: %#v", resolvedReq.Paths) + if first, second := batch.request(0), batch.request(1); first.SourcePath != "one.mkv" || second.SourcePath != "two.mkv" { + t.Fatalf("unexpected independent request sources: %q, %q", first.SourcePath, second.SourcePath) } } @@ -1491,44 +1650,13 @@ func TestPrepareCLIUploadMetadataReturnsResolvedPreviewPaths(t *testing.T) { t.Parallel() coreSvc := &cliCoreForTest{previewSourcePath: filepath.Join("folder", "movie.mkv")} - req := api.Request{Paths: []string{"folder"}} + req := api.Request{SourcePath: "folder"} resolvedReq, err := prepareCLIUploadMetadata(context.Background(), coreSvc, req) if err != nil { t.Fatalf("prepareCLIUploadMetadata: %v", err) } - if len(resolvedReq.Paths) != 1 || resolvedReq.Paths[0] != filepath.Join("folder", "movie.mkv") { - t.Fatalf("expected resolved preview path, got %#v", resolvedReq.Paths) - } -} - -func TestBuildCLIUploadDebugReviewsUsesPreparedResolvedPath(t *testing.T) { - t.Parallel() - - coreSvc := &cliCoreForTest{ - previewSourcePath: filepath.Join("folder", "movie.mkv"), - review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, - } - req := api.Request{Paths: []string{"folder"}} - resolvedReq, err := prepareCLIUploadMetadata(context.Background(), coreSvc, req) - if err != nil { - t.Fatalf("prepareCLIUploadMetadata: %v", err) - } - - reviews, err := buildCLIUploadDebugReviews(context.Background(), coreSvc, req.Paths, resolvedReq) - if err != nil { - t.Fatalf("buildCLIUploadDebugReviews: %v", err) - } - if len(reviews) != 1 { - t.Fatalf("expected one debug review, got %d", len(reviews)) - } - if reviews[0].SourcePath != "folder" { - t.Fatalf("expected debug review to retain original source label, got %q", reviews[0].SourcePath) - } - if len(coreSvc.requests) != 2 { - t.Fatalf("expected preview and review requests, got %#v", coreSvc.requests) - } - if got := coreSvc.requests[1]; got.name != "review" || len(got.req.Paths) != 1 || got.req.Paths[0] != filepath.Join("folder", "movie.mkv") { - t.Fatalf("expected debug review to use prepared resolved path, got %#v", got) + if resolvedReq.SourcePath != filepath.Join("folder", "movie.mkv") { + t.Fatalf("expected resolved preview path, got %q", resolvedReq.SourcePath) } } @@ -1578,6 +1706,43 @@ func TestPromptTrackerQuestionnairesSkipsRuleFailedTrackers(t *testing.T) { } } +func TestPromptTrackerQuestionnairesDebugStillSkipsIneligibleTrackers(t *testing.T) { + t.Parallel() + + questionnaire := &api.TrackerQuestionnaire{Fields: []api.TrackerQuestionnaireField{{ + Key: "type", + Label: "Type", + Required: true, + }}} + answers, changed, err := promptTrackerQuestionnaires(bufio.NewReader(strings.NewReader("eligible\n")), api.UploadReview{ + Trackers: []api.TrackerReview{ + { + Tracker: "BANNED", + Banned: true, + Questionnaire: questionnaire, + }, + { + Tracker: "BLOCKED", + RuleFailures: []api.RuleFailure{{Rule: "require_movie_only", Reason: "category tv is not movie"}}, + Questionnaire: questionnaire, + }, + { + Tracker: "ELIGIBLE", + Questionnaire: questionnaire, + }, + }, + }, cliOptions{Debug: true}) + if err != nil { + t.Fatalf("promptTrackerQuestionnaires: %v", err) + } + if !changed { + t.Fatal("expected eligible questionnaire answer to change") + } + if len(answers) != 1 || answers["ELIGIBLE"]["type"] != "eligible" { + t.Fatalf("expected only eligible questionnaire answers, got %#v", answers) + } +} + func TestPromptTrackerQuestionnairesDebugUnattendedAllowsBlankRequiredDefault(t *testing.T) { t.Parallel() @@ -1613,12 +1778,20 @@ func TestHandleBDMVPlaylistSelectionDoesNotPromptInUnattendedMode(t *testing.T) coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, - {File: "00002.mpls", Duration: 7100, Score: 0.9}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, + { + File: "00002.mpls", + Duration: 7100, + Score: 0.9, + }, }, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{Unattended: true}) + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{}, cliOptions{Unattended: true}) if err == nil { t.Fatal("expected unattended playlist selection error") } @@ -1638,14 +1811,22 @@ func TestHandleBDMVPlaylistSelectionAllowsUnattendedUseLargestPlaylist(t *testin coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, - {File: "00002.mpls", Duration: 7100, Score: 0.9}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, + { + File: "00002.mpls", + Duration: 7100, + Score: 0.9, + }, }, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{ + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{ Metadata: config.MetadataConfig{UseLargestPlaylist: true}, - }, api.NopLogger{}, cliOptions{Unattended: true}) + }, cliOptions{Unattended: true}) if err != nil { t.Fatalf("handleBDMVPlaylistSelection: %v", err) } @@ -1654,7 +1835,7 @@ func TestHandleBDMVPlaylistSelectionAllowsUnattendedUseLargestPlaylist(t *testin } } -func TestHandleBDMVPlaylistSelectionReturnsSaveErrorInUnattendedUseLargestPlaylist(t *testing.T) { +func TestHandleBDMVPlaylistSelectionUsesDirectInstructionWhenRememberSaveFails(t *testing.T) { t.Parallel() saveErr := errors.New("save failed") @@ -1666,20 +1847,28 @@ func TestHandleBDMVPlaylistSelectionReturnsSaveErrorInUnattendedUseLargestPlayli coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, }, savePlaylistErr: saveErr, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{ + batch := newCLIPreparationBatch(api.Request{}, []string{root}) + err := handleBDMVPlaylistSelection(context.Background(), &batch, coreSvc, config.Config{ Metadata: config.MetadataConfig{UseLargestPlaylist: true}, }, api.NopLogger{}, cliOptions{Unattended: true}) - if !errors.Is(err, saveErr) { - t.Fatalf("expected save error, got %v", err) + if err != nil { + t.Fatalf("playlist selection: %v", err) + } + if got := batch.items[0].playlistInstruction; !got.Set || !slices.Equal(got.Selected, []string{"00001.mpls"}) { + t.Fatalf("playlist instruction = %#v", got) } } -func TestHandleBDMVPlaylistSelectionReturnsSaveErrorInUnattendedSinglePlaylist(t *testing.T) { +func TestHandleBDMVPlaylistSelectionUsesSingleDirectInstructionWhenRememberSaveFails(t *testing.T) { t.Parallel() saveErr := errors.New("save failed") @@ -1691,14 +1880,22 @@ func TestHandleBDMVPlaylistSelectionReturnsSaveErrorInUnattendedSinglePlaylist(t coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, }, savePlaylistErr: saveErr, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{Unattended: true}) - if !errors.Is(err, saveErr) { - t.Fatalf("expected save error, got %v", err) + batch := newCLIPreparationBatch(api.Request{}, []string{root}) + err := handleBDMVPlaylistSelection(context.Background(), &batch, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{Unattended: true}) + if err != nil { + t.Fatalf("playlist selection: %v", err) + } + if got := batch.items[0].playlistInstruction; !got.Set || !slices.Equal(got.Selected, []string{"00001.mpls"}) { + t.Fatalf("playlist instruction = %#v", got) } } @@ -1717,16 +1914,20 @@ func TestHandleBDMVPlaylistSelectionGivesEachDiscFreshDeadline(t *testing.T) { coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, }, } start := time.Now() // UseLargestPlaylist auto-selects without prompting, keeping discovery on the // per-disc deadline path for every disc. - err := handleBDMVPlaylistSelection(context.Background(), roots, coreSvc, config.Config{ + err := handleBDMVPlaylistSelectionForPaths(context.Background(), roots, coreSvc, config.Config{ Metadata: config.MetadataConfig{UseLargestPlaylist: true}, - }, api.NopLogger{}, cliOptions{Unattended: true}) + }, cliOptions{Unattended: true}) if err != nil { t.Fatalf("handleBDMVPlaylistSelection: %v", err) } @@ -1793,13 +1994,21 @@ func TestHandleBDMVPlaylistSelectionReturnsOnPromptSaveCtxErr(t *testing.T) { coreSvc := &cliCoreForTest{ playlistSelectionErr: internalerrors.ErrNotFound, playlists: []api.PlaylistInfo{ - {File: "00001.mpls", Duration: 7200, Score: 1}, - {File: "00002.mpls", Duration: 7100, Score: 0.9}, + { + File: "00001.mpls", + Duration: 7200, + Score: 1, + }, + { + File: "00002.mpls", + Duration: 7100, + Score: 0.9, + }, }, savePlaylistErr: context.Canceled, } - err = handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{}) + err = handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{}, cliOptions{}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected prompt-loop save to surface context.Canceled, got %v", err) } @@ -1835,9 +2044,9 @@ func TestMaybeEditCLIDescriptionsSavesEditedGroupOnRequest(t *testing.T) { } defer func() { editCLIDescriptionFile = oldEditor }() - req := api.Request{Paths: []string{"movie.mkv"}, Trackers: []string{"AITHER"}} + req := api.Request{SourcePath: "movie.mkv", Trackers: []string{"AITHER"}} review := coreSvc.review - updatedReq, _, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("y\n")), req, review, cliOptions{}) + updatedReq, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("y\n")), req, review, cliOptions{}) if err != nil { t.Fatalf("maybeEditCLIDescriptions: %v", err) } @@ -1850,9 +2059,10 @@ func TestMaybeEditCLIDescriptionsSavesEditedGroupOnRequest(t *testing.T) { if len(updatedReq.DescriptionGroups) != 1 || updatedReq.DescriptionGroups[0].RawDescription != "edited description" { t.Fatalf("expected edited request description group, got %#v", updatedReq.DescriptionGroups) } - last := coreSvc.requests[len(coreSvc.requests)-1] - if last.name != "review" || len(last.req.DescriptionGroups) != 1 || last.req.DescriptionGroups[0].RawDescription != "edited description" { - t.Fatalf("expected rebuilt review with edited description group, got %#v", last) + for _, recorded := range coreSvc.requests { + if recorded.name == "review" { + t.Fatalf("expected final operation assessment to consume edited request without an intermediate review rebuild, got %#v", recorded) + } } } @@ -1889,8 +2099,8 @@ func TestMaybeEditCLIDescriptionsPromptsEachDescriptionGroup(t *testing.T) { } defer func() { editCLIDescriptionFile = oldEditor }() - req := api.Request{Paths: []string{"movie.mkv"}, Trackers: []string{"HDB", "HHD"}} - updatedReq, _, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("n\ny\n")), req, coreSvc.review, cliOptions{}) + req := api.Request{SourcePath: "movie.mkv", Trackers: []string{"HDB", "HHD"}} + updatedReq, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("n\ny\n")), req, coreSvc.review, cliOptions{}) if err != nil { t.Fatalf("maybeEditCLIDescriptions: %v", err) } @@ -1916,9 +2126,10 @@ func TestMaybeEditCLIDescriptionsPromptsEachDescriptionGroup(t *testing.T) { if updatedReq.DescriptionGroups[1].RawDescription != "unit3d generated description edited" { t.Fatalf("expected Unit3D group to be edited, got %#v", updatedReq.DescriptionGroups[1]) } - last := coreSvc.requests[len(coreSvc.requests)-1] - if last.name != "review" || len(last.req.DescriptionGroups) != 2 || last.req.DescriptionGroups[1].RawDescription != "unit3d generated description edited" { - t.Fatalf("expected rebuilt review with edited unit3d group, got %#v", last) + for _, recorded := range coreSvc.requests { + if recorded.name == "review" { + t.Fatalf("expected final operation assessment to consume edited groups without an intermediate review rebuild, got %#v", recorded) + } } } @@ -1933,11 +2144,11 @@ func TestMaybeEditCLIDescriptionsSkipsOnlyID(t *testing.T) { }}}, } req := api.Request{ - Paths: []string{"movie.mkv"}, - Trackers: []string{"AITHER"}, - Options: api.UploadOptions{OnlyID: true}, + SourcePath: "movie.mkv", + Trackers: []string{"AITHER"}, + Options: api.UploadOptions{OnlyID: true}, } - updatedReq, _, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("y\n")), req, api.UploadReview{}, cliOptions{}) + updatedReq, err := maybeEditCLIDescriptions(context.Background(), coreSvc, bufio.NewReader(strings.NewReader("y\n")), req, api.UploadReview{}, cliOptions{}) if err != nil { t.Fatalf("maybeEditCLIDescriptions: %v", err) } @@ -1952,13 +2163,15 @@ func TestMaybeEditCLIDescriptionsSkipsOnlyID(t *testing.T) { type cliCoreForTest struct { review api.UploadReview reviewResponses []api.UploadReview - dryRunPreview api.TrackerDryRunPreview callOrder []string requests []cliCoreRequestForTest previewPaths []string previewSourcePath string previewResponses []api.MetadataPreview runUploadPreparedCalls int + runTrackerDryRunCalls int + dryRunPlans []api.TrackerDryRunPlan + dryRunPreview api.TrackerDryRunPreview dupeSummary api.DupeCheckSummary screenshotPlan api.ScreenshotPlan screenshotResult api.ScreenshotResult @@ -2039,14 +2252,19 @@ type cliCoreRequestForTest struct { func (c *cliCoreForTest) recordRequest(name string, req api.Request) { copyReq := req - copyReq.Paths = append([]string(nil), req.Paths...) copyReq.Trackers = append([]string(nil), req.Trackers...) copyReq.TrackersRemove = append([]string(nil), req.TrackersRemove...) copyReq.IgnoreDupesFor = append([]string(nil), req.IgnoreDupesFor...) - copyReq.IgnoreTrackerRuleFailuresFor = append([]string(nil), req.IgnoreTrackerRuleFailuresFor...) + copyReq.RuleAuthorizations = make([]api.RuleAuthorization, len(req.RuleAuthorizations)) + for idx, authorization := range req.RuleAuthorizations { + copyReq.RuleAuthorizations[idx] = api.RuleAuthorization{ + Tracker: authorization.Tracker, + Rules: append([]string(nil), authorization.Rules...), + } + } copyReq.DescriptionGroups = api.CloneDescriptionBuilderGroups(req.DescriptionGroups) copyReq.TrackerQuestionnaireAnswers = cloneCLIQuestionnaireAnswersForTest(req.TrackerQuestionnaireAnswers) - copyReq.ExternalIDSelections = cloneCLIExternalIDSelectionsForTest(req.ExternalIDSelections) + copyReq.PlaylistInstruction.Selected = append([]string(nil), req.PlaylistInstruction.Selected...) c.requests = append(c.requests, cliCoreRequestForTest{name: name, req: copyReq}) } @@ -2061,10 +2279,6 @@ func cloneCLIQuestionnaireAnswersForTest(input map[string]map[string]string) map return cloned } -func (c *cliCoreForTest) RunUpload(context.Context, api.Request) (api.Result, error) { - return api.Result{}, nil -} - func (c *cliCoreForTest) RunUploadPrepared(ctx context.Context, req api.Request) (api.Result, error) { c.recordRequest("upload", req) c.runUploadPreparedCalls++ @@ -2074,6 +2288,39 @@ func (c *cliCoreForTest) RunUploadPrepared(ctx context.Context, req api.Request) return api.Result{UploadedCount: 1}, nil } +func (c *cliCoreForTest) RunAcceptedTrackerDryRun(_ context.Context, plan api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) { + c.dryRunPlans = append(c.dryRunPlans, plan) + input := plan.Input + req := api.Request{ + SourcePath: input.Release.SourcePath, + Trackers: append([]string(nil), input.Trackers...), + IgnoreDupesFor: append([]string(nil), input.IgnoreDupesFor...), + TrackerQuestionnaireAnswers: cloneCLIQuestionnaireAnswersForTest(input.QuestionnaireAnswers), + DescriptionGroups: api.CloneDescriptionBuilderGroups(input.DescriptionGroups), + TrackerIDOverrides: maps.Clone(input.TrackerIDOverrides), + TrackerConfigOverrides: input.TrackerConfigOverrides, + TrackerSiteOverrides: input.TrackerSiteOverrides, + ClientOverrides: input.ClientOverrides, + ImageHostOverrides: input.ImageHostOverrides, + ScreenshotOverrides: input.ScreenshotOverrides, + TorrentOverrides: input.TorrentOverrides, + Options: input.Options, + } + c.callOrder = append(c.callOrder, "debug") + c.recordRequest("debug", req) + c.runTrackerDryRunCalls++ + preview := c.dryRunPreview + if strings.TrimSpace(preview.SourcePath) == "" { + preview.SourcePath = input.Release.SourcePath + } + if len(preview.Trackers) == 0 { + for _, tracker := range c.review.Trackers { + preview.Trackers = append(preview.Trackers, tracker.DryRun) + } + } + return preview, nil +} + func (c *cliCoreForTest) FetchMetadataPreview(ctx context.Context, req api.Request) (api.MetadataPreview, error) { c.callOrder = append(c.callOrder, "preview") c.recordRequest("preview", req) @@ -2082,15 +2329,25 @@ func (c *cliCoreForTest) FetchMetadataPreview(ctx context.Context, req api.Reque if err := ctx.Err(); err != nil { return api.MetadataPreview{}, fmt.Errorf("preview: %w", err) } - if len(req.Paths) > 0 { - c.previewPaths = append(c.previewPaths, req.Paths[0]) + if req.SourcePath != "" { + c.previewPaths = append(c.previewPaths, req.SourcePath) } if len(c.previewResponses) > 0 { preview := c.previewResponses[0] c.previewResponses = c.previewResponses[1:] + if strings.TrimSpace(preview.SourcePath) == "" { + preview.SourcePath = req.SourcePath + } + if preview.Release.Generation == 0 { + preview.Release = api.ReleaseRef{SourcePath: preview.SourcePath, Generation: 1} + } return preview, nil } - return api.MetadataPreview{SourcePath: c.previewSourcePath}, nil + path := c.previewSourcePath + if strings.TrimSpace(path) == "" { + path = req.SourcePath + } + return api.MetadataPreview{SourcePath: path, Release: api.ReleaseRef{SourcePath: path, Generation: 1}}, nil } func (c *cliCoreForTest) FetchDescriptionBuilderPreview(_ context.Context, req api.Request) (api.DescriptionBuilderPreview, error) { @@ -2106,12 +2363,6 @@ func (c *cliCoreForTest) FetchPreparationPreview(context.Context, api.Request) ( return api.PreparationPreview{}, nil } -func (c *cliCoreForTest) FetchTrackerDryRunPreview(_ context.Context, req api.Request) (api.TrackerDryRunPreview, error) { - c.callOrder = append(c.callOrder, "dry-run") - c.recordRequest("dry-run", req) - return c.dryRunPreview, nil -} - func (c *cliCoreForTest) CheckDupes(_ context.Context, req api.Request) (api.DupeCheckSummary, error) { c.callOrder = append(c.callOrder, "dupes") c.recordRequest("dupes", req) @@ -2172,15 +2423,6 @@ func (c *cliCoreForTest) UploadImages(context.Context, api.Request, string, []ap return api.UploadImagesResult{}, nil } -func cloneCLIExternalIDSelectionsForTest(input map[string]api.ExternalIDSelection) map[string]api.ExternalIDSelection { - if len(input) == 0 { - return nil - } - cloned := make(map[string]api.ExternalIDSelection, len(input)) - maps.Copy(cloned, input) - return cloned -} - func (c *cliCoreForTest) DeleteUploadedImage(context.Context, api.Request, string, string) error { return nil } @@ -2213,6 +2455,14 @@ func (c *cliCoreForTest) DiscoverPlaylists(ctx context.Context, _ string) ([]api return c.playlists, c.discoverPlaylistsErr } +func (*cliCoreForTest) DetectDiscType(ctx context.Context, sourcePath string) (string, error) { + layout, err := sourcelayout.Resolve(ctx, sourcePath) + if err != nil { + return "", fmt.Errorf("resolve source layout: %w", err) + } + return layout.DiscType, nil +} + func (c *cliCoreForTest) SavePlaylistSelection(_ context.Context, _ string, playlists []string, _ bool) error { c.savedPlaylists = append(c.savedPlaylists[:0], playlists...) return c.savePlaylistErr diff --git a/cmd/upbrr/main.go b/cmd/upbrr/main.go index c7ee4af8..a6cd5b23 100644 --- a/cmd/upbrr/main.go +++ b/cmd/upbrr/main.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "io" - "maps" "net" "net/netip" "os" @@ -58,7 +57,18 @@ func printTerminalError(err error) { if err == nil { return } - fmt.Fprintf(os.Stderr, "error: %s\n", logging.SanitizeMessage(err.Error())) + fmt.Fprintf(os.Stderr, "error: %s\n", logging.SanitizeMessage(formatCLIError(err))) +} + +func formatCLIError(err error) string { + if failure, ok := api.AsOperationFailure(err); ok { + message := logging.SanitizeMessage(failure.Message) + if failure.Recovery != "" && failure.Recovery != api.OperationRecoveryNone { + return fmt.Sprintf("%s (recovery: %s)", message, failure.Recovery) + } + return message + } + return logging.SanitizeMessage(err.Error()) } // printTerminalWarning writes a sanitized CLI warning diagnostic to stderr. @@ -115,12 +125,66 @@ const cliQueueGatherTimeout = 10 * time.Minute const cliDiscDiscoveryTimeout = 5 * time.Minute // cliUploadOnlyTimeoutCap bounds how many per-item allowances the non-queue -// upload-only deadline may sum to. RunUploadPrepared processes every path in a -// single core call (its abort-on-first-error semantics are shared/immutable), so -// the deadline is budgeted by item count but capped so a huge --paths list cannot -// produce an effectively unbounded run-wide timeout. +// upload-only deadline may sum to. The deadline is budgeted by item count but +// capped so a huge path list cannot produce an effectively unbounded run. const cliUploadOnlyTimeoutCap = 50 +// cliPreparedUploadRunner executes requests whose preparation state is already cached. +type cliPreparedUploadRunner interface { + RunUploadPrepared(context.Context, api.Request) (api.Result, error) +} + +// cliAcceptedTrackerDryRunRunner builds payloads from one retained explicit +// duplicate-check outcome without repeating discovery or duplicate checks. +type cliAcceptedTrackerDryRunRunner interface { + RunAcceptedTrackerDryRun(context.Context, api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) +} + +// cliMetadataPreviewer prepares metadata and resolves the upload source path. +type cliMetadataPreviewer interface { + FetchMetadataPreview(context.Context, api.Request) (api.MetadataPreview, error) +} + +// cliUploadReviewer builds the final tracker-facing review for one prepared request. +type cliUploadReviewer interface { + BuildUploadReview(context.Context, api.Request) (api.UploadReview, error) +} + +// cliDVDMenuCore owns the DVD-menu operations used immediately before review/upload. +type cliDVDMenuCore interface { + ImportMenuImages(context.Context, api.Request, []string) error + CaptureDVDMenus(context.Context, api.Request) (api.DVDMenuCaptureResult, error) +} + +// cliHistoryReleaseDeleter deletes stored history for one source path. +type cliHistoryReleaseDeleter interface { + DeleteHistoryRelease(context.Context, string) error +} + +// cliHistoryPurger deletes all stored release history. +type cliHistoryPurger interface { + DeleteAllHistoryReleases(context.Context) (int, error) +} + +// cliPlaylistSelectionCore owns the BDMV playlist operations used before upload. +type cliPlaylistSelectionCore interface { + DetectDiscType(context.Context, string) (string, error) + DiscoverPlaylists(context.Context, string) ([]api.PlaylistInfo, error) + SavePlaylistSelection(context.Context, string, []string, bool) error + LoadPlaylistSelection(context.Context, string) (api.PlaylistSelection, error) +} + +// cliUploadOnlyCore is the complete capability set for non-interactive upload-only runs. +type cliUploadOnlyCore interface { + cliPreparedUploadRunner + cliAcceptedTrackerDryRunRunner + cliMetadataPreviewer + cliDVDMenuCore + cliDupeChecker +} + +// run dispatches serve mode or the CLI workflow and converts parse/runtime +// failures into stable process-exit errors for main. func run() error { api.SetApplicationBuild(version, buildIdentifier) @@ -263,7 +327,7 @@ func run() error { }() if opts.Cleanup { - deleted, err := coreSvc.DeleteAllHistoryReleases(setupCtx) + deleted, err := purgeCLIStoredReleases(setupCtx, coreSvc) if err != nil { return exitError(1, err) } @@ -301,7 +365,8 @@ func run() error { // (not setupCtx): handleBDMVPlaylistSelection applies its own per-disc // deadline internally and must keep the interactive prompt on an // untimed-but-cancelable context. - if err := handleBDMVPlaylistSelection(ctx, paths, coreSvc, cfg, logger, opts); err != nil { + batch := newCLIPreparationBatch(api.Request{}, paths) + if err := handleBDMVPlaylistSelection(ctx, &batch, coreSvc, cfg, logger, opts); err != nil { return exitError(1, err) } @@ -310,49 +375,69 @@ func run() error { if err != nil { return exitError(1, err) } + batch = batch.withDefaults(uploadReq) queueMode := strings.TrimSpace(opts.QueueName) != "" if queueMode { - return runCLIUploadOnlyQueue(ctx, coreSvc, uploadReq, paths, opts.Debug, logger) + return runCLIUploadOnlyQueue(ctx, coreSvc, batch, opts.Debug, logger) } - return runCLIUploadOnlyBatch(ctx, coreSvc, uploadReq, paths, opts.Debug, logger) + return runCLIUploadOnlyBatch(ctx, coreSvc, batch, opts.Debug, logger) } queueMode := strings.TrimSpace(opts.QueueName) != "" - return processCLIPaths(ctx, paths, queueMode, cliItemTimeout, logger, func(itemCtx context.Context, sourcePath string) error { + return processCLIPreparationItems(ctx, batch, queueMode, cliItemTimeout, logger, func(itemCtx context.Context, item cliPreparationItem) error { if opts.SiteCheck { - return runSiteCheckCLIPath(itemCtx, coreSvc, opts, visitedFlags, sourcePath, screens) - } - return runInteractiveCLIPathWithLogger(itemCtx, coreSvc, os.Args[1:], opts, visitedFlags, sourcePath, screens, cfg, logger) + return runSiteCheckCLIItem(itemCtx, coreSvc, opts, visitedFlags, item, screens) + } + return runInteractiveCLIPathWithLoggerAndPlaylist( + itemCtx, + coreSvc, + os.Args[1:], + opts, + visitedFlags, + item.originalPath, + item.playlistInstruction, + screens, + cfg, + logger, + ) }) } -// runCLIUploadOnlyBatch runs upload-only over all paths in a single -// RunUploadPrepared call (preserving its shared abort-on-first-error -// semantics) under a deadline budgeted by item count but capped at -// cliUploadOnlyTimeoutCap items so a large path list cannot create an -// effectively unbounded run-wide timeout. -func runCLIUploadOnlyBatch(ctx context.Context, coreSvc api.Core, uploadReq api.Request, paths []string, debug bool, logger api.Logger) error { - budgetItems := min(max(1, len(paths)), cliUploadOnlyTimeoutCap) +// runCLIUploadOnlyBatch processes upload-only paths sequentially under one +// capped deadline. Debug runs call only the shared tracker dry-run operation; +// live runs call upload execution and stop on the first error. +func runCLIUploadOnlyBatch(ctx context.Context, coreSvc cliUploadOnlyCore, batch cliPreparationBatch, debug bool, logger api.Logger) error { + budgetItems := min(max(1, len(batch.items)), cliUploadOnlyTimeoutCap) uploadCtx, uploadCancel := context.WithTimeout(ctx, time.Duration(budgetItems)*cliItemTimeout) defer uploadCancel() - preparedReq, err := prepareCLIUploadMetadata(uploadCtx, coreSvc, uploadReq) - if err != nil { - return exitError(1, err) - } - if err := prepareCLIImages(uploadCtx, coreSvc, preparedReq, logger, true); err != nil { - return exitError(1, err) - } - if debug { - reviews, err := buildCLIUploadDebugReviews(uploadCtx, coreSvc, paths, preparedReq) + for index := range batch.items { + itemReq := batch.request(index) + preparedReq, release, err := prepareCLIUploadMetadataWithRelease(uploadCtx, coreSvc, itemReq) if err != nil { return exitError(1, err) } - for _, review := range reviews { - printDebugUploadReview(review) + batch.items[index].resolvedPath = preparedReq.SourcePath + if err := prepareCLIImages(uploadCtx, coreSvc, preparedReq, logger, true); err != nil { + return exitError(1, err) + } + if debug { + dupeSummary, err := coreSvc.CheckDupes(uploadCtx, preparedReq) + if err != nil { + return exitError(1, err) + } + preview, err := runAcceptedCLIDebugDryRun(uploadCtx, coreSvc, preparedReq, release, dupeSummary, false) + if err != nil { + return exitError(1, err) + } + if strings.TrimSpace(batch.items[index].originalPath) != "" { + preview.SourcePath = batch.items[index].originalPath + } + printDebugTrackerDryRun(preview) + continue + } + if _, err := coreSvc.RunUploadPrepared(uploadCtx, preparedReq); err != nil { + return exitError(1, err) } - } - if _, err := coreSvc.RunUploadPrepared(uploadCtx, preparedReq); err != nil { - return exitError(1, err) } return nil } @@ -360,15 +445,17 @@ func runCLIUploadOnlyBatch(ctx context.Context, coreSvc api.Core, uploadReq api. // runCLIUploadOnlyQueue runs upload-only in queue mode: each gathered path is // processed independently under its own cliItemTimeout via processCLIPaths, so a // single failing item is logged and skipped (continue-on-error) instead of -// aborting the whole queue. Per item it prepares metadata, optionally prints the -// debug review, and runs RunUploadPrepared for that one path, summing uploaded -// counts across items. -func runCLIUploadOnlyQueue(ctx context.Context, coreSvc api.Core, uploadReq api.Request, paths []string, debug bool, logger api.Logger) error { +// aborting the whole queue. Per item it prepares metadata, then either runs and +// prints the shared debug payload preview or executes a live upload. Live runs +// sum accepted tracker uploads across items. +func runCLIUploadOnlyQueue(ctx context.Context, coreSvc cliUploadOnlyCore, batch cliPreparationBatch, debug bool, logger api.Logger) error { var uploaded int - err := processCLIPaths(ctx, paths, true, cliItemTimeout, logger, func(itemCtx context.Context, sourcePath string) error { - itemReq := uploadReq - itemReq.Paths = []string{sourcePath} - preparedReq, err := prepareCLIUploadMetadata(itemCtx, coreSvc, itemReq) + err := processCLIPreparationItems(ctx, batch, true, cliItemTimeout, logger, func(itemCtx context.Context, item cliPreparationItem) error { + itemReq := batch.defaults + itemReq.SourcePath = item.resolvedPath + itemReq.ExternalIDOverrides = item.externalIDs + itemReq.PlaylistInstruction = item.playlistInstruction + preparedReq, release, err := prepareCLIUploadMetadataWithRelease(itemCtx, coreSvc, itemReq) if err != nil { return err } @@ -376,24 +463,19 @@ func runCLIUploadOnlyQueue(ctx context.Context, coreSvc api.Core, uploadReq api. return err } if debug { - // Single-path review: mirror the inline debug-review build rather than - // buildCLIUploadDebugReviews, whose Paths[idx] indexing is fragile when - // the request is already a single path. - resolvedPath := sourcePath - if len(preparedReq.Paths) > 0 && strings.TrimSpace(preparedReq.Paths[0]) != "" { - resolvedPath = preparedReq.Paths[0] + dupeSummary, err := coreSvc.CheckDupes(itemCtx, preparedReq) + if err != nil { + return fmt.Errorf("check duplicates for tracker debug %q: %w", preparedReq.SourcePath, err) } - reviewReq := preparedReq - reviewReq.Paths = []string{resolvedPath} - reviewReq.ExternalIDSelections = cloneCLIExternalIDSelectionsForResolvedPath(preparedReq.ExternalIDSelections, sourcePath, resolvedPath) - review, err := coreSvc.BuildUploadReview(itemCtx, reviewReq) + preview, err := runAcceptedCLIDebugDryRun(itemCtx, coreSvc, preparedReq, release, dupeSummary, false) if err != nil { - return fmt.Errorf("build upload review for %q: %w", resolvedPath, err) + return fmt.Errorf("run tracker debug for %q: %w", preparedReq.SourcePath, err) } - if strings.TrimSpace(sourcePath) != "" { - review.SourcePath = sourcePath + if strings.TrimSpace(item.originalPath) != "" { + preview.SourcePath = item.originalPath } - printDebugUploadReview(review) + printDebugTrackerDryRun(preview) + return nil } result, err := coreSvc.RunUploadPrepared(itemCtx, preparedReq) // RunUploadPrepared can accept some tracker uploads and then fail on a @@ -406,6 +488,10 @@ func runCLIUploadOnlyQueue(ctx context.Context, coreSvc api.Core, uploadReq api. return nil }) if logger != nil { + if debug { + logger.Infof("queue: upload-only debug completed, no tracker uploads submitted") + return err + } // UploadedCount counts accepted tracker uploads, not queue items (one item // may upload to several trackers), so report it as tracker uploads. logger.Infof("queue: upload-only completed, %d tracker upload(s) accepted", uploaded) @@ -414,76 +500,71 @@ func runCLIUploadOnlyQueue(ctx context.Context, coreSvc api.Core, uploadReq api. } // prepareCLIImages imports manual menu paths and optionally captures automatic -// DVD menus for each request path before review or upload. Non-DVD paths are -// skipped without invoking capture. Batch callers can continue after an empty -// successful capture while retaining errors from CaptureDVDMenus. -func prepareCLIImages(ctx context.Context, coreSvc api.Core, req api.Request, logger api.Logger, continueOnEmptyCapture bool) error { +// DVD menus for one request source. Non-DVD paths are skipped without capture. +func prepareCLIImages(ctx context.Context, coreSvc cliDVDMenuCore, req api.Request, logger api.Logger, continueOnEmptyCapture bool) error { if coreSvc == nil { return errors.New("upbrr: core service is required") } if logger == nil { logger = api.NopLogger{} } - if len(req.Paths) == 0 { + if strings.TrimSpace(req.SourcePath) == "" { return internalerrors.ErrInvalidInput } - for _, sourcePath := range req.Paths { - singleReq := req - singleReq.Paths = []string{sourcePath} - if len(singleReq.ScreenshotOverrides.MenuPaths) > 0 { - if err := coreSvc.ImportMenuImages(ctx, singleReq, singleReq.ScreenshotOverrides.MenuPaths); err != nil { - return fmt.Errorf("upbrr: import menu images: %w", err) - } - } - if !singleReq.Options.CaptureDVDMenus { - continue + sourcePath := req.SourcePath + if len(req.ScreenshotOverrides.MenuPaths) > 0 { + if err := coreSvc.ImportMenuImages(ctx, req, req.ScreenshotOverrides.MenuPaths); err != nil { + return fmt.Errorf("upbrr: import menu images: %w", err) } + } + if !req.Options.CaptureDVDMenus { + return nil + } - discType, err := filesystem.DetectDiscType(ctx, sourcePath) - if err != nil { - return fmt.Errorf("upbrr: detect DVD menu source: %w", err) - } - if !strings.EqualFold(strings.TrimSpace(discType), "DVD") { - label := strings.TrimSpace(discType) - if label == "" { - label = "none" - } - logger.Infof("DVD menus: capture skipped disc_type=%s decision=skip", label) - fmt.Printf("DVD menu capture skipped: %s is not a DVD\n", formatPathLabel(sourcePath)) - continue + discType, err := filesystem.DetectDiscType(ctx, sourcePath) + if err != nil { + return fmt.Errorf("upbrr: detect DVD menu source: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(discType), "DVD") { + label := strings.TrimSpace(discType) + if label == "" { + label = "none" } + logger.Infof("DVD menus: capture skipped disc_type=%s decision=skip", label) + fmt.Printf("DVD menu capture skipped: %s is not a DVD\n", formatPathLabel(sourcePath)) + return nil + } - result, err := coreSvc.CaptureDVDMenus(ctx, singleReq) - if err != nil { - return fmt.Errorf("upbrr: capture DVD menus: %w", err) - } - if len(result.Images) == 0 { - if !continueOnEmptyCapture { - return errors.New("upbrr: capture DVD menus: no menu images captured") - } - logger.Errorf( - "DVD menus: capture failed source=%s decision=continue reason=no_images", - formatPathLabel(sourcePath), - ) - continue - } - if result.Partial { - logger.Warnf( - "DVD menus: capture incomplete captured=%d warnings=%d truncated=%t", - len(result.Images), - len(result.Warnings), - result.Truncated, - ) - fmt.Printf("DVD menus ready: %d (capture incomplete)\n", len(result.Images)) - continue - } - if result.Truncated { - fmt.Printf("DVD menus ready: %d (maximum reached)\n", len(result.Images)) - continue + result, err := coreSvc.CaptureDVDMenus(ctx, req) + if err != nil { + return fmt.Errorf("upbrr: capture DVD menus: %w", err) + } + if len(result.Images) == 0 { + if !continueOnEmptyCapture { + return errors.New("upbrr: capture DVD menus: no menu images captured") } - fmt.Printf("DVD menus ready: %d\n", len(result.Images)) + logger.Errorf( + "DVD menus: capture failed source=%s decision=continue reason=no_images", + formatPathLabel(sourcePath), + ) + return nil + } + if result.Partial { + logger.Warnf( + "DVD menus: capture incomplete captured=%d warnings=%d truncated=%t", + len(result.Images), + len(result.Warnings), + result.Truncated, + ) + fmt.Printf("DVD menus ready: %d (capture incomplete)\n", len(result.Images)) + return nil } + if result.Truncated { + fmt.Printf("DVD menus ready: %d (maximum reached)\n", len(result.Images)) + return nil + } + fmt.Printf("DVD menus ready: %d\n", len(result.Images)) return nil } @@ -492,7 +573,14 @@ func prepareCLIImages(ctx context.Context, coreSvc api.Core, req api.Request, lo // of the queue still runs, with a summary error returned at the end if any item // failed. Outside queue mode the first failure aborts immediately, preserving // the original single/multi-path behavior. -func processCLIPaths(ctx context.Context, paths []string, queueMode bool, itemTimeout time.Duration, logger api.Logger, process func(ctx context.Context, sourcePath string) error) error { +func processCLIPaths( + ctx context.Context, + paths []string, + queueMode bool, + itemTimeout time.Duration, + logger api.Logger, + process func(ctx context.Context, sourcePath string) error, +) error { failed := make([]string, 0) var firstErr error // abortOnCancel returns a terminal error when the parent context is done (a @@ -555,10 +643,15 @@ func processCLIPaths(ctx context.Context, paths []string, queueMode bool, itemTi // each queue item receives a full timeout budget rather than sharing one // run-wide deadline. The deadline is enforced cooperatively: itemCtx is threaded // into every core call (FetchMetadataPreview, CheckDupes, screenshot handling, -// BuildUploadReview, RunUploadPrepared, etc.) and honored only when those calls -// check ctx. A core operation that ignores ctx will run past itemTimeout; the +// BuildUploadReview, RunAcceptedTrackerDryRun, RunUploadPrepared, etc.) and honored only +// when those calls check ctx. A core operation that ignores ctx will run past itemTimeout; the // timeout cannot forcibly kill in-flight work. -func runCLIPathWithTimeout(ctx context.Context, itemTimeout time.Duration, sourcePath string, process func(ctx context.Context, sourcePath string) error) error { +func runCLIPathWithTimeout( + ctx context.Context, + itemTimeout time.Duration, + sourcePath string, + process func(ctx context.Context, sourcePath string) error, +) error { itemCtx, cancel := context.WithTimeout(ctx, itemTimeout) defer cancel() return process(itemCtx, sourcePath) @@ -1235,7 +1328,15 @@ func normalizeCLIPaths(ctx context.Context, paths []string) ([]string, error) { return normalized, nil } -func deleteCLIStoredReleases(ctx context.Context, coreSvc api.Core, paths []string) error { +func purgeCLIStoredReleases(ctx context.Context, coreSvc cliHistoryPurger) (int, error) { + count, err := coreSvc.DeleteAllHistoryReleases(ctx) + if err != nil { + return count, fmt.Errorf("purge stored releases: %w", err) + } + return count, nil +} + +func deleteCLIStoredReleases(ctx context.Context, coreSvc cliHistoryReleaseDeleter, paths []string) error { for _, sourcePath := range paths { if err := coreSvc.DeleteHistoryRelease(ctx, sourcePath); err != nil { return fmt.Errorf("delete stored data for %q: %w", sourcePath, err) @@ -1245,36 +1346,137 @@ func deleteCLIStoredReleases(ctx context.Context, coreSvc api.Core, paths []stri return nil } -func prepareCLIUploadMetadata(ctx context.Context, coreSvc api.Core, req api.Request) (api.Request, error) { - resolvedReq := req - resolvedPaths := make([]string, 0, len(req.Paths)) - resolvedSelections := req.ExternalIDSelections - for _, sourcePath := range req.Paths { - singleReq := req - singleReq.Paths = []string{sourcePath} - singleReq.ExternalIDSelections = resolvedSelections - preview, err := coreSvc.FetchMetadataPreview(ctx, singleReq) +// cliPreparationItem keeps source-specific resolution, identity, and playlist +// choices detached from batch defaults. +type cliPreparationItem struct { + originalPath string + resolvedPath string + externalIDs api.ExternalIDOverrides + playlistInstruction api.PlaylistInstruction +} + +// cliPreparationBatch owns immutable request defaults plus isolated per-source instructions. +type cliPreparationBatch struct { + defaults api.Request + items []cliPreparationItem +} + +// cloneCLIPlaylistInstruction detaches the selected-playlist slice. +func cloneCLIPlaylistInstruction(value api.PlaylistInstruction) api.PlaylistInstruction { + value.Selected = append([]string(nil), value.Selected...) + return value +} + +// cloneCLIExternalIDOverrides detaches all optional provider ID pointers. +func cloneCLIExternalIDOverrides(value api.ExternalIDOverrides) api.ExternalIDOverrides { + return api.ExternalIDOverrides{ + TMDBID: cloneCLIInt(value.TMDBID), + IMDBID: cloneCLIInt(value.IMDBID), + TVDBID: cloneCLIInt(value.TVDBID), + TVmazeID: cloneCLIInt(value.TVmazeID), + MALID: cloneCLIInt(value.MALID), + } +} + +// cloneCLIInt copies an optional integer pointer. +func cloneCLIInt(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +// newCLIPreparationBatch clones pointer- and slice-backed defaults for every source. +func newCLIPreparationBatch(defaults api.Request, paths []string) cliPreparationBatch { + items := make([]cliPreparationItem, 0, len(paths)) + for _, sourcePath := range paths { + items = append(items, cliPreparationItem{ + originalPath: sourcePath, + resolvedPath: sourcePath, + externalIDs: cloneCLIExternalIDOverrides(defaults.ExternalIDOverrides), + playlistInstruction: cloneCLIPlaylistInstruction(defaults.PlaylistInstruction), + }) + } + defaults.SourcePath = "" + return cliPreparationBatch{defaults: defaults, items: items} +} + +// withDefaults replaces shared defaults without overwriting an explicit +// source-specific playlist instruction. +func (b cliPreparationBatch) withDefaults(defaults api.Request) cliPreparationBatch { + defaults.SourcePath = "" + b.defaults = defaults + for index := range b.items { + b.items[index].externalIDs = cloneCLIExternalIDOverrides(defaults.ExternalIDOverrides) + if !b.items[index].playlistInstruction.Set { + b.items[index].playlistInstruction = cloneCLIPlaylistInstruction(defaults.PlaylistInstruction) + } + } + return b +} + +// request materializes one request using the item's resolved source path and +// source-specific identity and playlist choices. +func (b cliPreparationBatch) request(index int) api.Request { + item := b.items[index] + request := b.defaults + request.SourcePath = item.resolvedPath + request.ExternalIDOverrides = cloneCLIExternalIDOverrides(item.externalIDs) + request.PlaylistInstruction = cloneCLIPlaylistInstruction(item.playlistInstruction) + return request +} + +// processCLIPreparationItems delegates timeout and queue policy while preserving +// each item's source-scoped instructions. +func processCLIPreparationItems( + ctx context.Context, + batch cliPreparationBatch, + queueMode bool, + itemTimeout time.Duration, + logger api.Logger, + process func(context.Context, cliPreparationItem) error, +) error { + paths := make([]string, 0, len(batch.items)) + for _, item := range batch.items { + paths = append(paths, item.originalPath) + } + index := 0 + return processCLIPaths(ctx, paths, queueMode, itemTimeout, logger, func(itemCtx context.Context, _ string) error { + item := batch.items[index] + index++ + return process(itemCtx, item) + }) +} + +func prepareCLIUploadMetadata(ctx context.Context, coreSvc cliMetadataPreviewer, req api.Request) (api.Request, error) { + resolvedReq, _, err := prepareCLIUploadMetadataWithRelease(ctx, coreSvc, req) + return resolvedReq, err +} + +func prepareCLIUploadMetadataWithRelease( + ctx context.Context, + coreSvc cliMetadataPreviewer, + req api.Request, +) (api.Request, api.ReleaseRef, error) { + sourcePath := req.SourcePath + preview, err := coreSvc.FetchMetadataPreview(ctx, req) + if err != nil { + return api.Request{}, api.ReleaseRef{}, fmt.Errorf("upbrr: %w", err) + } + resolvedPath := resolvedCLIMetadataSourcePath(sourcePath, preview) + if shouldRefreshCLIResolvedMetadataPreview(req, sourcePath, resolvedPath) { + resolvedReq := req + resolvedReq.SourcePath = resolvedPath + preview, err = coreSvc.FetchMetadataPreview(ctx, resolvedReq) if err != nil { - return api.Request{}, fmt.Errorf("upbrr: %w", err) - } - resolvedPath := resolvedCLIMetadataSourcePath(sourcePath, preview) - resolvedSelections = cloneCLIExternalIDSelectionsForResolvedPath(resolvedSelections, sourcePath, resolvedPath) - if shouldRefreshCLIResolvedMetadataPreview(singleReq, sourcePath, resolvedPath) { - resolvedReq := singleReq - resolvedReq.Paths = []string{resolvedPath} - resolvedReq.ExternalIDSelections = resolvedSelections - preview, err = coreSvc.FetchMetadataPreview(ctx, resolvedReq) - if err != nil { - return api.Request{}, fmt.Errorf("upbrr: %w", err) - } - resolvedPath = resolvedCLIMetadataSourcePath(resolvedPath, preview) - resolvedSelections = cloneCLIExternalIDSelectionsForResolvedPath(resolvedSelections, sourcePath, resolvedPath) + return api.Request{}, api.ReleaseRef{}, fmt.Errorf("upbrr: %w", err) } - resolvedPaths = append(resolvedPaths, resolvedPath) + resolvedPath = resolvedCLIMetadataSourcePath(resolvedPath, preview) } - resolvedReq.Paths = resolvedPaths - resolvedReq.ExternalIDSelections = resolvedSelections - return resolvedReq, nil + resolvedReq := req + resolvedReq.SourcePath = resolvedPath + return resolvedReq, preview.Release, nil } func shouldRefreshCLIResolvedMetadataPreview(req api.Request, sourcePath string, resolvedPath string) bool { @@ -1286,11 +1488,7 @@ func shouldRefreshCLIResolvedMetadataPreview(req api.Request, sourcePath string, if filepath.Clean(trimmedSourcePath) == filepath.Clean(trimmedResolvedPath) { return false } - if cliHasExternalIDOverrides(req.ExternalIDOverrides) { - return true - } - _, ok := resolveCLIExternalIDSelection(req.ExternalIDSelections, sourcePath) - return ok + return cliHasExternalIDOverrides(req.ExternalIDOverrides) } func cliHasExternalIDOverrides(overrides api.ExternalIDOverrides) bool { @@ -1301,73 +1499,6 @@ func cliHasExternalIDOverrides(overrides api.ExternalIDOverrides) bool { overrides.MALID != nil } -// buildCLIUploadDebugReviews builds one review per original CLI source path, -// preserving the original display path while using any prepared resolved path. -func buildCLIUploadDebugReviews(ctx context.Context, coreSvc api.Core, sourcePaths []string, uploadReq api.Request) ([]api.UploadReview, error) { - reviews := make([]api.UploadReview, 0, len(sourcePaths)) - for idx, sourcePath := range sourcePaths { - resolvedPath := sourcePath - if idx < len(uploadReq.Paths) && strings.TrimSpace(uploadReq.Paths[idx]) != "" { - resolvedPath = uploadReq.Paths[idx] - } - debugReq := uploadReq - debugReq.Paths = []string{resolvedPath} - debugReq.ExternalIDSelections = cloneCLIExternalIDSelectionsForResolvedPath(uploadReq.ExternalIDSelections, sourcePath, resolvedPath) - review, err := coreSvc.BuildUploadReview(ctx, debugReq) - if err != nil { - return nil, fmt.Errorf("build upload review for %q: %w", resolvedPath, err) - } - if strings.TrimSpace(sourcePath) != "" { - review.SourcePath = sourcePath - } - reviews = append(reviews, review) - } - return reviews, nil -} - -func cloneCLIExternalIDSelectionsForResolvedPath(selections map[string]api.ExternalIDSelection, sourcePath string, resolvedPath string) map[string]api.ExternalIDSelection { - if len(selections) == 0 { - return selections - } - trimmedSourcePath := strings.TrimSpace(sourcePath) - trimmedResolvedPath := strings.TrimSpace(resolvedPath) - if trimmedResolvedPath == "" || trimmedSourcePath == "" { - return selections - } - if filepath.Clean(trimmedSourcePath) == filepath.Clean(trimmedResolvedPath) { - return selections - } - selected, ok := resolveCLIExternalIDSelection(selections, sourcePath) - if !ok { - return selections - } - // Current source-path selections must replace any stale resolved-path - // selections carried from a previous run or source change. - cloned := make(map[string]api.ExternalIDSelection, len(selections)+1) - maps.Copy(cloned, selections) - cloned[trimmedResolvedPath] = selected - return cloned -} - -func resolveCLIExternalIDSelection(selections map[string]api.ExternalIDSelection, sourcePath string) (api.ExternalIDSelection, bool) { - if len(selections) == 0 { - return api.ExternalIDSelection{}, false - } - if selected, ok := selections[sourcePath]; ok { - return selected, true - } - cleanedSourcePath := filepath.Clean(sourcePath) - if selected, ok := selections[cleanedSourcePath]; ok { - return selected, true - } - for key, selected := range selections { - if filepath.Clean(key) == cleanedSourcePath { - return selected, true - } - } - return api.ExternalIDSelection{}, false -} - // isCtxErr reports whether err is a context cancellation/deadline error, // signalling the run-wide deadline or an explicit cancel has fired. BDMV // setup must abort (not skip the path) when this happens so cancellation is @@ -1376,24 +1507,35 @@ func isCtxErr(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } -// handleBDMVPlaylistSelection resolves playlist selections for each BDMV disc in -// paths. ctx is the untimed-but-cancelable parent context; each disc gets its +// handleBDMVPlaylistSelection resolves and attaches one playlist instruction to +// each BDMV preparation item. ctx is the untimed-but-cancelable parent context; each disc gets its // own cliDiscDiscoveryTimeout for the timed detection/load/discover/save work so // one slow disc cannot starve the rest of the queue. The interactive playlist // prompt runs on the parent (cancelable, untimed) context, not the per-disc // deadline, so a long human wait does not abort. -func handleBDMVPlaylistSelection(ctx context.Context, paths []string, coreSvc api.Core, cfg config.Config, logger api.Logger, opts cliOptions) error { - if len(paths) == 0 { +func handleBDMVPlaylistSelection( + ctx context.Context, + batch *cliPreparationBatch, + coreSvc cliPlaylistSelectionCore, + cfg config.Config, + logger api.Logger, + opts cliOptions, +) error { + if batch == nil || len(batch.items) == 0 { return nil } - for _, path := range paths { + for index := range batch.items { + path := batch.items[index].originalPath discCtx, discCancel := context.WithTimeout(ctx, cliDiscDiscoveryTimeout) - err := handleBDMVDiscSelection(discCtx, ctx, path, coreSvc, cfg, logger, opts) + instruction, err := handleBDMVDiscSelection(discCtx, ctx, path, coreSvc, cfg, logger, opts) discCancel() if err != nil { return err } + if instruction.Set { + batch.items[index].playlistInstruction = instruction + } } return nil } @@ -1405,19 +1547,27 @@ func handleBDMVPlaylistSelection(ctx context.Context, paths []string, coreSvc ap // human wait does not trip the per-disc deadline. A context error from any timed // operation aborts (returns) rather than skipping the path, surfacing // cancellation instead of swallowing it. -func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, path string, coreSvc api.Core, cfg config.Config, logger api.Logger, opts cliOptions) error { - // Check if this path is a BDMV folder - discType, err := filesystem.DetectDiscType(discCtx, path) +func handleBDMVDiscSelection( + discCtx context.Context, + promptCtx context.Context, + path string, + coreSvc cliPlaylistSelectionCore, + cfg config.Config, + logger api.Logger, + opts cliOptions, +) (api.PlaylistInstruction, error) { + // Classify the preparation source without requiring the CLI to derive its disc root. + discType, err := coreSvc.DetectDiscType(discCtx, path) if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV disc type detection cancelled for %s: %w", path, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV disc type detection cancelled for %s: %w", path, err) } logger.Debugf("cli: disc type detection failed for %s: %v", path, err) - return nil + return api.PlaylistInstruction{}, nil } if discType != "BDMV" { - return nil + return api.PlaylistInstruction{}, nil } logger.Infof("cli: BDMV disc detected at %s", path) @@ -1426,22 +1576,26 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, absPath, err := filepath.Abs(path) if err != nil { logger.Warnf("cli: resolve path %s: %v", path, err) - return nil + return api.PlaylistInstruction{}, nil } // Check if playlist selection is already persisted - _, err = coreSvc.LoadPlaylistSelection(discCtx, absPath) + remembered, err := coreSvc.LoadPlaylistSelection(discCtx, absPath) if err == nil { logger.Infof("cli: using previously saved playlist selection for %s", absPath) - return nil + return api.PlaylistInstruction{ + Set: true, + Selected: append([]string(nil), remembered.SelectedPlaylists...), + UseAll: remembered.UseAll, + }, nil } if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection load cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection load cancelled for %s: %w", absPath, err) } if !errors.Is(err, internalerrors.ErrNotFound) { logger.Warnf("cli: load playlist selection: %v", err) - return nil + return api.PlaylistInstruction{}, nil } } @@ -1452,35 +1606,32 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, playlists, err := coreSvc.DiscoverPlaylists(discCtx, absPath) if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist discovery cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist discovery cancelled for %s: %w", absPath, err) } if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV playlist discovery failed for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: unattended BDMV playlist discovery failed for %s: %w", absPath, err) } logger.Warnf("cli: discover playlists: %v", err) - return nil + return api.PlaylistInstruction{}, nil } if len(playlists) == 0 { if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV upload found no playlists for %s", absPath) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: unattended BDMV upload found no playlists for %s", absPath) } - return nil + return api.PlaylistInstruction{}, nil } // Save the best (highest-scoring) playlist selected := []string{playlists[0].File} if err := coreSvc.SavePlaylistSelection(discCtx, absPath, selected, false); err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) - } - if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV playlist selection save failed for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) } logger.Warnf("cli: save playlist selection: %v", err) } else { logger.Infof("cli: auto-selected playlist %s (score: %.2f)", playlists[0].File, playlists[0].Score) } - return nil + return api.PlaylistInstruction{Set: true, Selected: selected}, nil } // Interactive selection required @@ -1488,26 +1639,29 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, playlists, err := coreSvc.DiscoverPlaylists(discCtx, absPath) if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist discovery cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist discovery cancelled for %s: %w", absPath, err) } if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV playlist discovery failed for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: unattended BDMV playlist discovery failed for %s: %w", absPath, err) } logger.Warnf("cli: discover playlists: %v", err) - return nil + return api.PlaylistInstruction{}, nil } if len(playlists) == 0 { if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV upload found no playlists for %s", absPath) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: unattended BDMV upload found no playlists for %s", absPath) } logger.Warnf("cli: no playlists found for %s", absPath) - return nil + return api.PlaylistInstruction{}, nil } logger.Infof("cli: found %d playlists", len(playlists)) if opts.Unattended && !opts.UnattendedConfirm && len(playlists) > 1 { - return fmt.Errorf("upbrr: unattended BDMV upload requires a saved playlist selection or use_largest_playlist for %s", absPath) + return api.PlaylistInstruction{}, fmt.Errorf( + "upbrr: unattended BDMV upload requires a saved playlist selection or use_largest_playlist for %s", + absPath, + ) } // Display top playlists and prompt user @@ -1516,14 +1670,11 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, fmt.Printf("[*] Auto-selecting...\n") if err := coreSvc.SavePlaylistSelection(discCtx, absPath, []string{playlists[0].File}, false); err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) - } - if opts.Unattended && !opts.UnattendedConfirm { - return fmt.Errorf("upbrr: unattended BDMV playlist selection save failed for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) } logger.Warnf("cli: save playlist selection: %v", err) } - return nil + return api.PlaylistInstruction{Set: true, Selected: []string{playlists[0].File}}, nil } // Display top 5 playlists @@ -1540,6 +1691,7 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, // promptCtx (NOT discCtx) so a long human wait does not trip the per-disc // deadline; the saves inside it are bounded by a fresh short deadline derived // from promptCtx so they remain cancelable but the user-wait is not timed. + var instruction api.PlaylistInstruction for { fmt.Printf("\nEnter playlist numbers (comma-separated), 'ALL' to select all top %d, or press Enter to auto-select best: ", topCount) var input string @@ -1550,12 +1702,13 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, } if n == 0 || strings.TrimSpace(input) == "" { // Auto-select best + instruction = api.PlaylistInstruction{Set: true, Selected: []string{playlists[0].File}} saveCtx, saveCancel := context.WithTimeout(promptCtx, cliDiscDiscoveryTimeout) err := coreSvc.SavePlaylistSelection(saveCtx, absPath, []string{playlists[0].File}, false) saveCancel() if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) } logger.Warnf("cli: save playlist selection: %v", err) } else { @@ -1570,12 +1723,17 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, for i := range topCount { selected = append(selected, playlists[i].File) } + instruction = api.PlaylistInstruction{ + Set: true, + Selected: append([]string(nil), selected...), + UseAll: true, + } saveCtx, saveCancel := context.WithTimeout(promptCtx, cliDiscDiscoveryTimeout) err := coreSvc.SavePlaylistSelection(saveCtx, absPath, selected, true) saveCancel() if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) } logger.Warnf("cli: save playlist selection: %v", err) } else { @@ -1601,12 +1759,13 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, } if valid && len(selected) > 0 { + instruction = api.PlaylistInstruction{Set: true, Selected: append([]string(nil), selected...)} saveCtx, saveCancel := context.WithTimeout(promptCtx, cliDiscDiscoveryTimeout) err := coreSvc.SavePlaylistSelection(saveCtx, absPath, selected, false) saveCancel() if err != nil { if isCtxErr(err) { - return fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) + return api.PlaylistInstruction{}, fmt.Errorf("upbrr: BDMV playlist selection save cancelled for %s: %w", absPath, err) } logger.Warnf("cli: save playlist selection: %v", err) } else { @@ -1617,7 +1776,7 @@ func handleBDMVDiscSelection(discCtx context.Context, promptCtx context.Context, fmt.Printf("[!] Please try again.\n") } - return nil + return instruction, nil } func formatDuration(seconds float64) string { diff --git a/cmd/upbrr/main_test.go b/cmd/upbrr/main_test.go index 5cf2ea53..428003f3 100644 --- a/cmd/upbrr/main_test.go +++ b/cmd/upbrr/main_test.go @@ -6,9 +6,11 @@ package main import ( "context" "errors" + "fmt" "net" "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -17,10 +19,64 @@ import ( "github.com/autobrr/upbrr/internal/config" internalerrors "github.com/autobrr/upbrr/internal/errors" "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/sourcelayout" "github.com/autobrr/upbrr/internal/webserver" "github.com/autobrr/upbrr/pkg/api" ) +func runCLIUploadOnlyQueueForPaths( + ctx context.Context, + coreSvc cliUploadOnlyCore, + request api.Request, + paths []string, +) error { + return runCLIUploadOnlyQueue(ctx, coreSvc, newCLIPreparationBatch(request, paths), false, api.NopLogger{}) +} + +func TestCLIPreparationBatchOwnsPerSourceInstructions(t *testing.T) { + t.Parallel() + + tmdbID := 1234567 + defaults := api.Request{ + ExternalIDOverrides: api.ExternalIDOverrides{TMDBID: &tmdbID}, + PlaylistInstruction: api.PlaylistInstruction{Set: true, Selected: []string{"00001.mpls"}}, + } + batch := newCLIPreparationBatch(defaults, []string{"one", "two"}) + *defaults.ExternalIDOverrides.TMDBID = 7654321 + defaults.PlaylistInstruction.Selected[0] = "99999.mpls" + *batch.items[0].externalIDs.TMDBID = 1111111 + batch.items[0].playlistInstruction.Selected[0] = "00002.mpls" + + if got := *batch.items[1].externalIDs.TMDBID; got != 1234567 { + t.Fatalf("second item TMDB ID = %d, want detached value", got) + } + if got := batch.items[1].playlistInstruction.Selected[0]; got != "00001.mpls" { + t.Fatalf("second item playlist = %q, want detached value", got) + } +} + +func runCLIUploadOnlyBatchForPaths( + ctx context.Context, + coreSvc cliUploadOnlyCore, + request api.Request, + paths []string, + debug bool, + logger api.Logger, +) error { + return runCLIUploadOnlyBatch(ctx, coreSvc, newCLIPreparationBatch(request, paths), debug, logger) +} + +func handleBDMVPlaylistSelectionForPaths( + ctx context.Context, + paths []string, + coreSvc cliPlaylistSelectionCore, + cfg config.Config, + opts cliOptions, +) error { + batch := newCLIPreparationBatch(api.Request{}, paths) + return handleBDMVPlaylistSelection(ctx, &batch, coreSvc, cfg, api.NopLogger{}, opts) +} + func TestProcessCLIPathsQueueModeContinuesOnError(t *testing.T) { t.Parallel() @@ -80,9 +136,11 @@ func TestProcessCLIPathsAppliesPerItemTimeout(t *testing.T) { t.Parallel() paths := []string{"slow", "fast"} - fastSawFreshDeadline := false + var slowDeadline time.Time + var fastDeadline time.Time err := processCLIPaths(context.Background(), paths, true, 20*time.Millisecond, api.NopLogger{}, func(itemCtx context.Context, sourcePath string) error { if sourcePath == "slow" { + slowDeadline, _ = itemCtx.Deadline() // Exceed the per-item timeout; this item should be cancelled at // ~20ms rather than running to completion. select { @@ -94,16 +152,14 @@ func TestProcessCLIPathsAppliesPerItemTimeout(t *testing.T) { } // The next item must receive a fresh per-item deadline, proving the // timeout is not shared across the queue. - if deadline, ok := itemCtx.Deadline(); ok && time.Until(deadline) > 5*time.Millisecond { - fastSawFreshDeadline = true - } + fastDeadline, _ = itemCtx.Deadline() return nil }) if err == nil { t.Fatal("expected a summary error because the slow item timed out") } - if !fastSawFreshDeadline { - t.Fatal("expected the second item to get a fresh per-item deadline") + if slowDeadline.IsZero() || fastDeadline.IsZero() || !fastDeadline.After(slowDeadline) { + t.Fatalf("expected the second item to get a later per-item deadline: slow=%v fast=%v", slowDeadline, fastDeadline) } } @@ -394,9 +450,6 @@ func TestRunHelpFlagsPrintUsageAndSucceed(t *testing.T) { t.Fatalf("expected output to contain %q, got %q", expected, output) } } - if strings.Contains(output, "-gui") { - t.Fatalf("expected GUI flag to be absent from help, got %q", output) - } }) } } @@ -451,7 +504,11 @@ func TestServePersistConfigListenOnlyClearsStoredBaseURL(t *testing.T) { runtime.Port = 9090 runtime.BaseURL = "/temporary/" - persisted := servePersistConfig(stored, runtime, map[string]bool{"persist-listen": true, "addr": true, "base-url": true}) + persisted := servePersistConfig(stored, runtime, map[string]bool{ + "persist-listen": true, + "addr": true, + "base-url": true, + }) if persisted.Host != "0.0.0.0" || persisted.Port != 9090 { t.Fatalf("listen settings not persisted: %#v", persisted) } @@ -464,21 +521,38 @@ func TestServePersistConfigListenOnlyClearsStoredBaseURL(t *testing.T) { } func TestServePersistConfigWebConfigPersistsBaseURL(t *testing.T) { - stored := webserver.CLIConfig{Host: "localhost", Port: 7480, BaseURL: "/stored/"} + stored := webserver.CLIConfig{ + Host: "localhost", + Port: 7480, + BaseURL: "/stored/", + } runtime := stored runtime.Host = "0.0.0.0" runtime.Port = 9090 runtime.BaseURL = "/explicit/" - persisted := servePersistConfig(stored, runtime, map[string]bool{"persist-listen": true, "persist-web-config": true, "addr": true, "base-url": true}) + persisted := servePersistConfig(stored, runtime, map[string]bool{ + "persist-listen": true, + "persist-web-config": true, + "addr": true, + "base-url": true, + }) if persisted.Host != "0.0.0.0" || persisted.Port != 9090 || persisted.BaseURL != "/explicit/" { t.Fatalf("explicit web config not persisted: %#v", persisted) } } func TestServePersistConfigListenOnlyPersistsExplicitListenFields(t *testing.T) { - stored := webserver.CLIConfig{Host: "localhost", Port: 7480, BaseURL: "/stored/"} - runtime := webserver.CLIConfig{Host: "0.0.0.0", Port: 9090, BaseURL: "/env/"} + stored := webserver.CLIConfig{ + Host: "localhost", + Port: 7480, + BaseURL: "/stored/", + } + runtime := webserver.CLIConfig{ + Host: "0.0.0.0", + Port: 9090, + BaseURL: "/env/", + } persisted := servePersistConfig(stored, runtime, map[string]bool{"persist-listen": true, "port": true}) if persisted.Host != "localhost" || persisted.Port != 9090 || persisted.BaseURL != "" { @@ -489,8 +563,16 @@ func TestServePersistConfigListenOnlyPersistsExplicitListenFields(t *testing.T) func TestServePersistConfigListenOnlyIgnoresInvalidStoredBaseURLWhenSaving(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "state", "upbrr.db") - stored := webserver.CLIConfig{Host: "localhost", Port: 7480, BaseURL: "javascript:alert(1)"} - runtime := webserver.CLIConfig{Host: "127.0.0.1", Port: 9090, BaseURL: "javascript:alert(1)"} + stored := webserver.CLIConfig{ + Host: "localhost", + Port: 7480, + BaseURL: "javascript:alert(1)", + } + runtime := webserver.CLIConfig{ + Host: "127.0.0.1", + Port: 9090, + BaseURL: "javascript:alert(1)", + } persisted := servePersistConfig(stored, runtime, map[string]bool{"persist-listen": true, "host": true}) if err := webserver.SaveCLIConfig(dbPath, persisted); err != nil { @@ -538,7 +620,7 @@ func TestRunServePersistListenBindFailureDoesNotWriteWebConfig(t *testing.T) { defer listener.Close() tmpDir := t.TempDir() - distPath := filepath.Join(tmpDir, "gui", "frontend", "dist") + distPath := filepath.Join(tmpDir, "webui", "dist") if err := os.MkdirAll(distPath, 0o755); err != nil { t.Fatalf("create web assets fixture: %v", err) } @@ -669,7 +751,7 @@ func TestRunExportConfigPlaintextExportsPlainSecrets(t *testing.T) { } } -func TestPrepareCLIUploadMetadataRefreshesResolvedPathForExternalSelections(t *testing.T) { +func TestPrepareCLIUploadMetadataKeepsExternalOverridesAfterResolvedPath(t *testing.T) { t.Parallel() sourcePath := "folder" @@ -684,10 +766,8 @@ func TestPrepareCLIUploadMetadataRefreshesResolvedPathForExternalSelections(t *t }, } req := api.Request{ - Paths: []string{sourcePath}, - ExternalIDSelections: map[string]api.ExternalIDSelection{ - sourcePath: {TMDBID: &tmdbID, MALID: &malID}, - }, + SourcePath: sourcePath, + ExternalIDOverrides: api.ExternalIDOverrides{TMDBID: &tmdbID, MALID: &malID}, } resolvedReq, err := prepareCLIUploadMetadata(context.Background(), coreSvc, req) @@ -697,65 +777,36 @@ func TestPrepareCLIUploadMetadataRefreshesResolvedPathForExternalSelections(t *t if len(coreSvc.requests) != 2 { t.Fatalf("expected 2 preview requests, got %#v", coreSvc.requests) } - if len(coreSvc.requests[0].req.Paths) != 1 || coreSvc.requests[0].req.Paths[0] != sourcePath { - t.Fatalf("expected first preview for source path, got %#v", coreSvc.requests[0].req.Paths) - } - if len(coreSvc.requests[1].req.Paths) != 1 || coreSvc.requests[1].req.Paths[0] != resolvedPath { - t.Fatalf("expected second preview for resolved path, got %#v", coreSvc.requests[1].req.Paths) + if coreSvc.requests[0].req.SourcePath != sourcePath { + t.Fatalf("expected first preview for source path, got %q", coreSvc.requests[0].req.SourcePath) } - if len(resolvedReq.Paths) != 1 || resolvedReq.Paths[0] != resolvedPath { - t.Fatalf("expected resolved upload path, got %#v", resolvedReq.Paths) + if coreSvc.requests[1].req.SourcePath != resolvedPath { + t.Fatalf("expected second preview for resolved path, got %q", coreSvc.requests[1].req.SourcePath) } - selected, ok := resolveCLIExternalIDSelection(resolvedReq.ExternalIDSelections, resolvedPath) - if !ok || selected.TMDBID == nil || *selected.TMDBID != tmdbID { - t.Fatalf("expected resolved-path external selection, got %#v", resolvedReq.ExternalIDSelections) + if resolvedReq.SourcePath != resolvedPath { + t.Fatalf("expected resolved upload path, got %q", resolvedReq.SourcePath) } - if selected.MALID == nil || *selected.MALID != malID { - t.Fatalf("expected resolved-path mal selection, got %#v", resolvedReq.ExternalIDSelections) + if resolvedReq.ExternalIDOverrides.TMDBID == nil || *resolvedReq.ExternalIDOverrides.TMDBID != tmdbID { + t.Fatalf("expected external TMDB override to remain source-local, got %#v", resolvedReq.ExternalIDOverrides) } - secondSelected, ok := coreSvc.requests[1].req.ExternalIDSelections[resolvedPath] - if !ok || secondSelected.TMDBID == nil || *secondSelected.TMDBID != tmdbID { - t.Fatalf("expected resolved-path selection on second preview, got %#v", coreSvc.requests[1].req.ExternalIDSelections) + if resolvedReq.ExternalIDOverrides.MALID == nil || *resolvedReq.ExternalIDOverrides.MALID != malID { + t.Fatalf("expected MAL override to remain source-local, got %#v", resolvedReq.ExternalIDOverrides) } - if secondSelected.MALID == nil || *secondSelected.MALID != malID { - t.Fatalf("expected second preview mal selection, got %#v", coreSvc.requests[1].req.ExternalIDSelections) + if second := coreSvc.requests[1].req.ExternalIDOverrides; second.TMDBID == nil || *second.TMDBID != tmdbID || second.MALID == nil || *second.MALID != malID { + t.Fatalf("expected second preview to retain source-local overrides, got %#v", second) } } -func TestPrepareCLIUploadMetadataRefreshesStaleResolvedPathExternalSelections(t *testing.T) { +func TestCLIPreparationBatchKeepsSourceChoicesIndependent(t *testing.T) { t.Parallel() - sourcePath := "folder" - resolvedPath := filepath.Join("folder", "movie.mkv") - currentTMDBID := 12345 - staleTMDBID := 99999 - - coreSvc := &cliCoreForTest{ - previewResponses: []api.MetadataPreview{ - {SourcePath: resolvedPath}, - {SourcePath: resolvedPath}, - }, - } - req := api.Request{ - Paths: []string{sourcePath}, - ExternalIDSelections: map[string]api.ExternalIDSelection{ - sourcePath: {TMDBID: ¤tTMDBID}, - resolvedPath: {TMDBID: &staleTMDBID}, - }, - } - - resolvedReq, err := prepareCLIUploadMetadata(context.Background(), coreSvc, req) - if err != nil { - t.Fatalf("prepareCLIUploadMetadata: %v", err) - } - - selected, ok := resolvedReq.ExternalIDSelections[resolvedPath] - if !ok || selected.TMDBID == nil || *selected.TMDBID != currentTMDBID { - t.Fatalf("expected resolved upload selection to refresh stale TMDB ID, got %#v", resolvedReq.ExternalIDSelections) - } - secondSelected, ok := coreSvc.requests[1].req.ExternalIDSelections[resolvedPath] - if !ok || secondSelected.TMDBID == nil || *secondSelected.TMDBID != currentTMDBID { - t.Fatalf("expected second preview to refresh stale TMDB ID, got %#v", coreSvc.requests[1].req.ExternalIDSelections) + firstID, secondID := 12345, 99999 + batch := newCLIPreparationBatch(api.Request{}, []string{"one.mkv", "two.mkv"}) + batch.items[0].externalIDs.TMDBID = &firstID + batch.items[1].externalIDs.TMDBID = &secondID + first, second := batch.request(0), batch.request(1) + if *first.ExternalIDOverrides.TMDBID != firstID || *second.ExternalIDOverrides.TMDBID != secondID { + t.Fatalf("source choices crossed batch items: first=%#v second=%#v", first.ExternalIDOverrides, second.ExternalIDOverrides) } } @@ -835,9 +886,9 @@ func TestRunCLIUploadOnlyQueueContinuesOnError(t *testing.T) { uploadPaths := make([]string, 0) coreSvc := &cliCoreForTest{ runUploadFunc: func(_ context.Context, req api.Request) (api.Result, error) { - if len(req.Paths) > 0 { - uploadPaths = append(uploadPaths, req.Paths[0]) - if req.Paths[0] == failOn { + if req.SourcePath != "" { + uploadPaths = append(uploadPaths, req.SourcePath) + if req.SourcePath == failOn { return api.Result{}, errors.New("upload boom") } } @@ -845,7 +896,7 @@ func TestRunCLIUploadOnlyQueueContinuesOnError(t *testing.T) { }, } paths := []string{"good1", failOn, "good2"} - err := runCLIUploadOnlyQueue(context.Background(), coreSvc, api.Request{}, paths, false, api.NopLogger{}) + err := runCLIUploadOnlyQueueForPaths(context.Background(), coreSvc, api.Request{}, paths) if err == nil { t.Fatal("expected a summary error when one queue item fails") } @@ -862,7 +913,7 @@ func TestRunCLIUploadOnlyQueueSumsUploadedCounts(t *testing.T) { coreSvc := &cliCoreForTest{} paths := []string{"a", "b", "c"} - err := runCLIUploadOnlyQueue(context.Background(), coreSvc, api.Request{}, paths, false, api.NopLogger{}) + err := runCLIUploadOnlyQueueForPaths(context.Background(), coreSvc, api.Request{}, paths) if err != nil { t.Fatalf("expected no error for all-passing queue, got %v", err) } @@ -873,8 +924,8 @@ func TestRunCLIUploadOnlyQueueSumsUploadedCounts(t *testing.T) { if r.name != "upload" { continue } - if len(r.req.Paths) != 1 { - t.Fatalf("expected single-path upload request per item, got %#v", r.req.Paths) + if strings.TrimSpace(r.req.SourcePath) == "" { + t.Fatal("expected nonblank single-source upload request per item") } } } @@ -890,9 +941,9 @@ func TestRunCLIUploadOnlyQueueCapturesOnlyDVDItems(t *testing.T) { coreSvc := &cliCoreForTest{dvdMenuResult: api.DVDMenuCaptureResult{ Images: []api.DVDMenuCaptureImage{{ScreenshotImage: api.ScreenshotImage{Path: "dvd-menu.png", Purpose: api.ScreenshotPurposeMenu}}}, }} - err := runCLIUploadOnlyQueue(context.Background(), coreSvc, api.Request{ + err := runCLIUploadOnlyQueueForPaths(context.Background(), coreSvc, api.Request{ Options: api.UploadOptions{CaptureDVDMenus: true}, - }, []string{dvdRoot, nonDiscRoot}, false, api.NopLogger{}) + }, []string{dvdRoot, nonDiscRoot}) if err != nil { t.Fatalf("upload-only queue: %v", err) } @@ -912,9 +963,9 @@ func TestRunCLIUploadOnlyQueueDVDMenuCaptureFailureStopsUpload(t *testing.T) { t.Fatalf("create VIDEO_TS: %v", err) } coreSvc := &cliCoreForTest{dvdMenuErr: errSyntheticDVDMenuCapture} - err := runCLIUploadOnlyQueue(context.Background(), coreSvc, api.Request{ + err := runCLIUploadOnlyQueueForPaths(context.Background(), coreSvc, api.Request{ Options: api.UploadOptions{CaptureDVDMenus: true}, - }, []string{dvdRoot}, false, api.NopLogger{}) + }, []string{dvdRoot}) assertDVDMenuCaptureError(t, err) if got := strings.Join(coreSvc.callOrder, ","); got != "preview,capture-dvd-menus" { @@ -935,9 +986,9 @@ func TestRunCLIUploadOnlyQueueRecordsEachEmptyDVDMenuCapture(t *testing.T) { } } coreSvc := &cliCoreForTest{} - err := runCLIUploadOnlyQueue(context.Background(), coreSvc, api.Request{ + err := runCLIUploadOnlyQueueForPaths(context.Background(), coreSvc, api.Request{ Options: api.UploadOptions{CaptureDVDMenus: true}, - }, paths, false, api.NopLogger{}) + }, paths) if err == nil { t.Fatal("expected queue summary error for empty DVD menu captures") } @@ -967,7 +1018,7 @@ func TestRunCLIUploadOnlyBatchBoundsTimeoutByItemCap(t *testing.T) { for i := range paths { paths[i] = "p" + strconv.Itoa(i) } - if err := runCLIUploadOnlyBatch(context.Background(), coreSvc, api.Request{Paths: paths}, paths, false, api.NopLogger{}); err != nil { + if err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, api.Request{}, paths, false, api.NopLogger{}); err != nil { t.Fatalf("runCLIUploadOnlyBatch: %v", err) } if !sawDeadline { @@ -980,8 +1031,8 @@ func TestRunCLIUploadOnlyBatchBoundsTimeoutByItemCap(t *testing.T) { if remaining >= time.Duration(len(paths))*cliItemTimeout { t.Fatalf("expected bounded deadline below len(paths)*cliItemTimeout, got %v", remaining) } - if coreSvc.runUploadPreparedCalls != 1 { - t.Fatalf("expected a single batch RunUploadPrepared call, got %d", coreSvc.runUploadPreparedCalls) + if coreSvc.runUploadPreparedCalls != len(paths) { + t.Fatalf("expected one independent RunUploadPrepared call per source, got %d", coreSvc.runUploadPreparedCalls) } } @@ -1000,7 +1051,7 @@ func TestRunCLIUploadOnlyBatchSinglePathTimeout(t *testing.T) { }, } paths := []string{"only"} - if err := runCLIUploadOnlyBatch(context.Background(), coreSvc, api.Request{Paths: paths}, paths, false, api.NopLogger{}); err != nil { + if err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, api.Request{}, paths, false, api.NopLogger{}); err != nil { t.Fatalf("runCLIUploadOnlyBatch: %v", err) } if !sawDeadline { @@ -1014,7 +1065,7 @@ func TestRunCLIUploadOnlyBatchSinglePathTimeout(t *testing.T) { } } -func TestRunCLIUploadOnlyBatchCapturesBeforeDebugReviews(t *testing.T) { +func TestRunCLIUploadOnlyBatchCapturesBeforeTrackerDebugRun(t *testing.T) { t.Parallel() dvdRoot := t.TempDir() @@ -1025,18 +1076,89 @@ func TestRunCLIUploadOnlyBatchCapturesBeforeDebugReviews(t *testing.T) { dvdMenuResult: api.DVDMenuCaptureResult{ Images: []api.DVDMenuCaptureImage{{ScreenshotImage: api.ScreenshotImage{Path: "dvd-menu.png", Purpose: api.ScreenshotPurposeMenu}}}, }, - review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "BLU"}}}, + dupeSummary: api.DupeCheckSummary{Results: []api.DupeCheckResult{{ +Tracker: "BLU", + Status: "completed", + Notes: []string{"retained"}, +}}}, } paths := []string{dvdRoot} - if err := runCLIUploadOnlyBatch(context.Background(), coreSvc, api.Request{ - Paths: paths, - Options: api.UploadOptions{CaptureDVDMenus: true}, + if err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, api.Request{ + Trackers: []string{"BLU"}, + Options: api.UploadOptions{CaptureDVDMenus: true}, }, paths, true, api.NopLogger{}); err != nil { t.Fatalf("upload-only batch: %v", err) } - if got := strings.Join(coreSvc.callOrder, ","); got != "preview,capture-dvd-menus,review" { + if got := strings.Join(coreSvc.callOrder, ","); got != "preview,capture-dvd-menus,dupes,debug" { t.Fatalf("batch call order = %s", got) } + if coreSvc.runUploadPreparedCalls != 0 || coreSvc.runTrackerDryRunCalls != 1 { + t.Fatalf("debug batch selected wrong operation: upload=%d debug=%d", coreSvc.runUploadPreparedCalls, coreSvc.runTrackerDryRunCalls) + } + if len(coreSvc.dryRunPlans) != 1 { + t.Fatalf("accepted dry-run plans = %d, want 1", len(coreSvc.dryRunPlans)) + } + plan := coreSvc.dryRunPlans[0] + if plan.Duplicate.Release != plan.Input.Release || plan.Duplicate.Release.Generation != 1 { + t.Fatalf("accepted duplicate release = %#v input=%#v", plan.Duplicate.Release, plan.Input.Release) + } + if len(plan.Duplicate.Results) != 1 || plan.Duplicate.Results[0].Notes[0] != "retained" { + t.Fatalf("accepted duplicate results = %#v", plan.Duplicate.Results) + } +} + +func TestRunCLIUploadOnlyBatchDebugLogLevelStillUploads(t *testing.T) { + t.Parallel() + + coreSvc := &cliCoreForTest{} + request := api.Request{Options: api.UploadOptions{RunLogLevel: "debug"}} + if err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, request, []string{t.TempDir()}, false, api.NopLogger{}); err != nil { + t.Fatalf("upload-only batch: %v", err) + } + if coreSvc.runUploadPreparedCalls != 1 || coreSvc.runTrackerDryRunCalls != 0 { + t.Fatalf("debug log level changed operation: upload=%d debug=%d", coreSvc.runUploadPreparedCalls, coreSvc.runTrackerDryRunCalls) + } + var uploadRequest api.Request + for _, recorded := range coreSvc.requests { + if recorded.name == "upload" { + uploadRequest = recorded.req + } + } + if uploadRequest.Options.RunLogLevel != "debug" { + t.Fatalf("run log level = %q, want debug", uploadRequest.Options.RunLogLevel) + } +} + +func TestRunCLIUploadOnlyQueueDebugUsesTrackerDryRun(t *testing.T) { + t.Parallel() + + coreSvc := &cliCoreForTest{dupeSummary: api.DupeCheckSummary{Results: []api.DupeCheckResult{{Tracker: "BLU", Status: "completed"}}}} + paths := []string{t.TempDir(), t.TempDir()} + err := runCLIUploadOnlyQueue( + context.Background(), + coreSvc, + newCLIPreparationBatch(api.Request{Trackers: []string{"BLU"}}, paths), + true, + api.NopLogger{}, + ) + if err != nil { + t.Fatalf("upload-only queue: %v", err) + } + if coreSvc.runUploadPreparedCalls != 0 || coreSvc.runTrackerDryRunCalls != len(paths) { + t.Fatalf("debug queue selected wrong operation: upload=%d debug=%d", coreSvc.runUploadPreparedCalls, coreSvc.runTrackerDryRunCalls) + } + if len(coreSvc.dryRunPlans) != len(paths) { + t.Fatalf("debug queue accepted plans = %d, want %d", len(coreSvc.dryRunPlans), len(paths)) + } + dupeCalls := 0 + for _, call := range coreSvc.callOrder { + if call == "dupes" { + dupeCalls++ + } + } + if dupeCalls != len(paths) { + t.Fatalf("debug queue duplicate checks = %d, want %d", dupeCalls, len(paths)) + } } func TestRunCLIUploadOnlyBatchContinuesAfterEmptyDVDMenuCapture(t *testing.T) { @@ -1050,8 +1172,7 @@ func TestRunCLIUploadOnlyBatchContinuesAfterEmptyDVDMenuCapture(t *testing.T) { } coreSvc := &cliCoreForTest{} logger := &cliErrorCountingLogger{} - if err := runCLIUploadOnlyBatch(context.Background(), coreSvc, api.Request{ - Paths: paths, + if err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, api.Request{ Options: api.UploadOptions{CaptureDVDMenus: true}, }, paths, false, logger); err != nil { t.Fatalf("upload-only batch: %v", err) @@ -1062,8 +1183,8 @@ func TestRunCLIUploadOnlyBatchContinuesAfterEmptyDVDMenuCapture(t *testing.T) { if logger.errors != len(paths) { t.Fatalf("recorded capture failures = %d, want %d", logger.errors, len(paths)) } - if coreSvc.runUploadPreparedCalls != 1 { - t.Fatalf("upload calls = %d, want 1", coreSvc.runUploadPreparedCalls) + if coreSvc.runUploadPreparedCalls != len(paths) { + t.Fatalf("upload calls = %d, want %d", coreSvc.runUploadPreparedCalls, len(paths)) } } @@ -1076,8 +1197,7 @@ func TestRunCLIUploadOnlyBatchDVDMenuCaptureFailureStopsReviewAndUpload(t *testi } coreSvc := &cliCoreForTest{dvdMenuErr: errSyntheticDVDMenuCapture} paths := []string{dvdRoot} - err := runCLIUploadOnlyBatch(context.Background(), coreSvc, api.Request{ - Paths: paths, + err := runCLIUploadOnlyBatchForPaths(context.Background(), coreSvc, api.Request{ Options: api.UploadOptions{CaptureDVDMenus: true}, }, paths, true, api.NopLogger{}) @@ -1116,7 +1236,7 @@ func TestHandleBDMVPlaylistSelectionReturnsOnDetectDiscTypeCancel(t *testing.T) root := newBDMVTempDir(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - err := handleBDMVPlaylistSelection(ctx, []string{root}, &cliCoreForTest{}, config.Config{}, api.NopLogger{}, cliOptions{}) + err := handleBDMVPlaylistSelectionForPaths(ctx, []string{root}, &cliCoreForTest{}, config.Config{}, cliOptions{}) if err == nil || !isCtxErr(err) { t.Fatalf("expected a context error from DetectDiscType, got %v", err) } @@ -1127,7 +1247,7 @@ func TestHandleBDMVPlaylistSelectionReturnsOnLoadSelectionCtxErr(t *testing.T) { root := newBDMVTempDir(t) coreSvc := &cliCoreForTest{playlistSelectionErr: context.DeadlineExceeded} - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{}) + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{}, cliOptions{}) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expected DeadlineExceeded from LoadPlaylistSelection, got %v", err) } @@ -1142,9 +1262,9 @@ func TestHandleBDMVPlaylistSelectionReturnsOnDiscoverPlaylistsCtxErr(t *testing. playlistSelectionErr: internalerrors.ErrNotFound, discoverPlaylistsErr: context.Canceled, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{ + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{ Metadata: config.MetadataConfig{UseLargestPlaylist: useLargest}, - }, api.NopLogger{}, cliOptions{}) + }, cliOptions{}) if !errors.Is(err, context.Canceled) { t.Fatalf("UseLargestPlaylist=%v: expected Canceled from DiscoverPlaylists, got %v", useLargest, err) } @@ -1161,9 +1281,9 @@ func TestHandleBDMVPlaylistSelectionReturnsOnSaveSelectionCtxErr(t *testing.T) { playlists: []api.PlaylistInfo{{File: "00800.mpls", Score: 1}}, savePlaylistErr: context.Canceled, } - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{ + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{ Metadata: config.MetadataConfig{UseLargestPlaylist: useLargest}, - }, api.NopLogger{}, cliOptions{}) + }, cliOptions{}) if !errors.Is(err, context.Canceled) { t.Fatalf("UseLargestPlaylist=%v: expected Canceled from SavePlaylistSelection, got %v", useLargest, err) } @@ -1175,8 +1295,84 @@ func TestHandleBDMVPlaylistSelectionSkipsNonCtxErrors(t *testing.T) { root := newBDMVTempDir(t) coreSvc := &cliCoreForTest{playlistSelectionErr: errors.New("boom")} - err := handleBDMVPlaylistSelection(context.Background(), []string{root}, coreSvc, config.Config{}, api.NopLogger{}, cliOptions{}) + err := handleBDMVPlaylistSelectionForPaths(context.Background(), []string{root}, coreSvc, config.Config{}, cliOptions{}) if err != nil { t.Fatalf("expected non-ctx load error to be skipped (continue), got %v", err) } } + +type perSourcePlaylistCore struct { + playlists map[string][]api.PlaylistInfo +} + +func (*perSourcePlaylistCore) DetectDiscType(ctx context.Context, sourcePath string) (string, error) { + layout, err := sourcelayout.Resolve(ctx, sourcePath) + if err != nil { + return "", fmt.Errorf("resolve source layout: %w", err) + } + return layout.DiscType, nil +} + +func (c *perSourcePlaylistCore) DiscoverPlaylists(_ context.Context, sourcePath string) ([]api.PlaylistInfo, error) { + return append([]api.PlaylistInfo(nil), c.playlists[filepath.Clean(sourcePath)]...), nil +} + +func (*perSourcePlaylistCore) SavePlaylistSelection(context.Context, string, []string, bool) error { + return nil +} + +func (*perSourcePlaylistCore) LoadPlaylistSelection(context.Context, string) (api.PlaylistSelection, error) { + return api.PlaylistSelection{}, internalerrors.ErrNotFound +} + +func TestCLITwoBDMVSourcesKeepIndependentPlaylistInstructions(t *testing.T) { + t.Parallel() + + paths := []string{filepath.Join(t.TempDir(), "disc-one"), filepath.Join(t.TempDir(), "disc-two")} + for _, sourcePath := range paths { + if err := os.MkdirAll(filepath.Join(sourcePath, "BDMV"), 0o700); err != nil { + t.Fatalf("create BDMV source: %v", err) + } + } + selectionCore := &perSourcePlaylistCore{playlists: map[string][]api.PlaylistInfo{ + filepath.Clean(paths[0]): {{File: "00001.mpls", Score: 2}}, + filepath.Clean(paths[1]): {{File: "00002.mpls", Score: 2}}, + }} + batch := newCLIPreparationBatch(api.Request{}, paths) + if err := handleBDMVPlaylistSelection( + context.Background(), + &batch, + selectionCore, + config.Config{Metadata: config.MetadataConfig{UseLargestPlaylist: true}}, + api.NopLogger{}, + cliOptions{Unattended: true}, + ); err != nil { + t.Fatalf("resolve playlist selections: %v", err) + } + if got := batch.items[0].playlistInstruction; !got.Set || !slices.Equal(got.Selected, []string{"00001.mpls"}) { + t.Fatalf("first instruction = %#v", got) + } + if got := batch.items[1].playlistInstruction; !got.Set || !slices.Equal(got.Selected, []string{"00002.mpls"}) { + t.Fatalf("second instruction = %#v", got) + } + + workflowCore := &cliCoreForTest{} + if err := runCLIUploadOnlyBatch(context.Background(), workflowCore, batch, false, api.NopLogger{}); err != nil { + t.Fatalf("run upload-only batch: %v", err) + } + var uploads []api.Request + for _, recorded := range workflowCore.requests { + if recorded.name == "upload" { + uploads = append(uploads, recorded.req) + } + } + if len(uploads) != 2 { + t.Fatalf("upload requests = %d, want 2", len(uploads)) + } + for index := range uploads { + if uploads[index].SourcePath != paths[index] || + !slices.Equal(uploads[index].PlaylistInstruction.Selected, batch.items[index].playlistInstruction.Selected) { + t.Fatalf("upload %d = %#v", index, uploads[index]) + } + } +} diff --git a/cmd/upbrr/operation_failure_test.go b/cmd/upbrr/operation_failure_test.go new file mode 100644 index 00000000..daf87228 --- /dev/null +++ b/cmd/upbrr/operation_failure_test.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package main + +import ( + "errors" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestFormatCLIErrorUsesStableOperationFailure(t *testing.T) { + err := api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureConfirmationRequired, + Operation: api.OperationKindPreparation, + Message: "Blu-ray playlist changes require confirmation before rescanning.", + Recovery: api.OperationRecoveryConfirm, + }, errors.New("private database path and token")) + + if got, want := formatCLIError(err), "Blu-ray playlist changes require confirmation before rescanning. (recovery: confirm)"; got != want { + t.Fatalf("formatCLIError() = %q, want %q", got, want) + } +} diff --git a/go.mod b/go.mod index 427ac118..59e4984b 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 github.com/moistari/rls v0.6.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/crypto v0.52.0 golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 @@ -27,29 +26,17 @@ replace github.com/moistari/rls => github.com/autobrr/rls v0.8.1 replace github.com/anacrolix/dht/v2 => github.com/anacrolix/dht/v2 v2.24.0 require ( - git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/v2 v2.10.0 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect - github.com/bep/debounce v1.2.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/labstack/echo/v4 v4.13.3 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/leaanthony/go-ansi-parser v1.6.1 // indirect - github.com/leaanthony/gosod v1.0.4 // indirect - github.com/leaanthony/slicer v1.6.0 // indirect - github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect @@ -61,14 +48,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/samber/lo v1.49.1 // indirect github.com/schollz/progressbar/v3 v3.19.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/tkrajina/go-reflector v0.5.8 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.22 // indirect - github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/go.sum b/go.sum index a26d3e22..a84f390f 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= -git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= -git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= @@ -60,8 +58,6 @@ github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylH github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= -github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= @@ -103,13 +99,9 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= -github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -145,8 +137,6 @@ github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORR github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -157,8 +147,6 @@ github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63 github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= -github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -176,23 +164,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= -github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= -github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= -github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= -github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= -github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= -github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= -github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= -github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= -github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= -github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -253,14 +224,11 @@ github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4 github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= -github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= -github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -282,18 +250,6 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= -github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= -github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= -github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= -github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= @@ -322,7 +278,6 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -345,19 +300,14 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/gui/build/appicon.png b/gui/build/appicon.png deleted file mode 100644 index cabc9c0a..00000000 Binary files a/gui/build/appicon.png and /dev/null differ diff --git a/gui/build/windows/icon.ico b/gui/build/windows/icon.ico deleted file mode 100644 index 97d4738f..00000000 Binary files a/gui/build/windows/icon.ico and /dev/null differ diff --git a/gui/build/windows/info.json b/gui/build/windows/info.json deleted file mode 100644 index 9727946b..00000000 --- a/gui/build/windows/info.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "fixed": { - "file_version": "{{.Info.ProductVersion}}" - }, - "info": { - "0000": { - "ProductVersion": "{{.Info.ProductVersion}}", - "CompanyName": "{{.Info.CompanyName}}", - "FileDescription": "{{.Info.ProductName}}", - "LegalCopyright": "{{.Info.Copyright}}", - "ProductName": "{{.Info.ProductName}}", - "Comments": "{{.Info.Comments}}" - } - } -} \ No newline at end of file diff --git a/gui/build/windows/wails.exe.manifest b/gui/build/windows/wails.exe.manifest deleted file mode 100644 index 17e1a238..00000000 --- a/gui/build/windows/wails.exe.manifest +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - true/pm - permonitorv2,permonitor - - - \ No newline at end of file diff --git a/gui/frontend/AGENTS.md b/gui/frontend/AGENTS.md deleted file mode 100644 index c3fe8010..00000000 --- a/gui/frontend/AGENTS.md +++ /dev/null @@ -1,72 +0,0 @@ -# Frontend Guidelines - -Scoped rules for `gui/frontend`. Root repo rules still apply. - -## Source Of Truth - -- Scripts and dependencies: `package.json`, `pnpm-lock.yaml`. -- TypeScript config: `tsconfig*.json`, `vite.config.ts`, `vitest.config.ts`. -- Lint/format behavior: ESLint, Prettier, Stylelint config files and Lefthook. -- API/runtime contracts: `pkg/api`, `internal/guiapp`, `internal/webserver`, generated Wails bindings when present. - -## Commands - -```bash -pnpm --dir gui/frontend run typecheck -pnpm --dir gui/frontend run test:unit -pnpm --dir gui/frontend run lint -pnpm --dir gui/frontend run lint:style -pnpm --dir gui/frontend run format:check -pnpm --dir gui/frontend run build -``` - -## Check Selection - -- TS/TSX changes: `pnpm --dir gui/frontend run lint`, `lint:dead`, `typecheck`, `test:unit`, and `format:check`. -- CSS changes: `pnpm --dir gui/frontend run lint:style`; also run `format:check`. -- Runtime/API bridge or Wails binding changes: frontend `test:unit` + `typecheck`, plus backend/API parity tests from `internal/AGENTS.md` and `pkg/api/AGENTS.md`. -- Bundle/import/env changes: `pnpm --dir gui/frontend run build`. -- Visual/embedded behavior changes: rebuild/sync embedded assets and inspect `http://localhost:7480`; avoid Vite `5173` for parity. - -`make test-frontend` runs lint, dead-code, typecheck, unit, and format checks, but not Stylelint. Run Stylelint explicitly for CSS. - -## React / TypeScript - -- Keep TypeScript, ESLint, Stylelint, dead-code clean. Do not weaken rules. -- `useEffect` only for external sync. Avoid derived state in effects; render or `useMemo` instead. -- User-driven logic belongs in handlers. Fetch effects need cleanup/abort guards. -- Preserve CLI, Wails, and embedded web parity when changing request shapes, upload options, prepared metadata, or runtime bridge behavior. -- Match existing component state patterns before adding new abstraction. - -## Frontend Output / Logging - -- Follow root log-level guidance for browser-visible diagnostics and runtime bridge logging. -- Do not expose credentials, tokens, API keys, passkeys, cookies, 2FA codes, challenge IDs, or secret payloads in console output, UI errors, toasts, test failure text, or debug panels. -- Avoid permanent `console.*` diagnostics. If a diagnostic is intentionally kept, make it dev-scoped, concise, and redacted. -- User-facing errors should be stable outcomes or next steps; detailed troubleshooting context belongs in developer diagnostics. - -## Styling - -- Prefer Tailwind utilities for touched local layout/spacing. -- Keep CSS for shared/theme/cross-cutting selectors or JSX readability. -- Do not make repo-wide format/style sweeps unless explicitly requested. -- Text must fit containers across desktop/mobile; do not rely on viewport-width font scaling. - -## Embedded Web Checks - -- For embedded visual/runtime checks, rebuild frontend, sync embedded assets, rebuild CLI, then serve the embedded app: - -```bash -pnpm --dir gui/frontend run build -pwsh -NoProfile -File .\scripts\sync-frontend-assets.ps1 -go build -o .\dist\upbrr.exe .\cmd\upbrr -.\dist\upbrr.exe serve --dev-no-auth -``` - -- Use `http://localhost:7480`. -- Avoid Vite `5173` for embedded parity checks. -- Stop local servers after inspection. - -## E2E - -For Playwright E2E work, read `e2e/AGENTS.md` first. E2E tests must use the embedded web UI, local fake services, isolated temp config/DB, and no real credentials. diff --git a/gui/frontend/CLAUDE.md b/gui/frontend/CLAUDE.md deleted file mode 120000 index c3170642..00000000 --- a/gui/frontend/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/gui/frontend/e2e/AGENTS.md b/gui/frontend/e2e/AGENTS.md deleted file mode 100644 index 0970bbb4..00000000 --- a/gui/frontend/e2e/AGENTS.md +++ /dev/null @@ -1,64 +0,0 @@ -# E2E Guidelines - -Scoped rules for Playwright E2E under `gui/frontend/e2e`. Root and frontend rules still apply. - -## Commands - -```bash -make e2e -make e2e-web -make e2e-cli -make e2e-wails -pnpm --dir gui/frontend run test:e2e:web -pnpm --dir gui/frontend run test:e2e:full -``` - -`make e2e` is the preferred full local command. It installs frontend deps, builds the frontend, syncs embedded assets, builds `dist/upbrr-e2e.exe` with the `e2e` tag, and runs all Playwright projects. - -If Playwright browsers are missing: - -```bash -pnpm --dir gui/frontend exec playwright install chromium -``` - -To open the HTML report from repo root: - -```bash -pnpm --dir gui/frontend exec playwright show-report -``` - -## Projects - -- `web-smoke`: embedded server at `http://localhost:7480`, `--dev-no-auth`; nav/settings/invalid-input smoke coverage. -- `web-full-upload`: metadata, screenshot/image upload, tracker dry-run, tracker upload, and history through embedded web UI. -- `cli-full-upload`: full CLI upload path against local fakes and temp config/DB. -- `wails-basic`: Go parity tests for Wails/web/shared API packages; no native desktop UI automation. - -## Harness Rules - -- Web UI E2E uses the embedded app as source of truth, not Vite. -- Use isolated temp workspace per test: config YAML, SQLite DB, media/torrent/screenshot fixtures. -- Use local fake tracker/image-host/torrent/metadata services only. -- No real tracker, image host, torrent client, TMDB, or credentials in E2E. -- Service seams must be test-only or config/test fixture driven; production defaults stay unchanged. -- Process manager must clean up `dist/upbrr-e2e.exe serve --config \config.yaml --dev-no-auth`. - -## Generated Artifacts - -Ignored local outputs: - -- `gui/frontend/playwright-report/` -- `gui/frontend/test-results/` - -Do not commit Playwright traces, videos, screenshots, reports, temp DBs, or `dist/upbrr-e2e.exe`. - -## CI - -Manual workflow only: - -- `.github/workflows/e2e.yml` -- `workflow_dispatch` -- Builds frontend + embedded assets + CLI. -- Installs Playwright Chromium. -- Runs `make e2e`. -- Uploads report/traces on failure. diff --git a/gui/frontend/e2e/CLAUDE.md b/gui/frontend/e2e/CLAUDE.md deleted file mode 120000 index c3170642..00000000 --- a/gui/frontend/e2e/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/gui/frontend/e2e/wails-basic.spec.ts b/gui/frontend/e2e/wails-basic.spec.ts deleted file mode 100644 index cdc54d5d..00000000 --- a/gui/frontend/e2e/wails-basic.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { spawn } from "node:child_process"; -import { expect, test } from "@playwright/test"; -import { repoRoot } from "./helpers/e2eHarness"; - -test("Wails/backend parity Go tests pass", async () => { - const result = await run("go", [ - "test", - "-race", - "-v", - "-timeout", - "20m", - "./internal/guiapp", - "./internal/webserver", - "./internal/guishared", - "./pkg/api", - ]); - expect(result.code, result.output).toBe(0); -}); - -function run(command: string, args: string[]): Promise<{ code: number | null; output: string }> { - return new Promise((resolve) => { - const child = spawn(command, args, { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - const chunks: string[] = []; - child.stdout?.on("data", (chunk) => chunks.push(String(chunk))); - child.stderr?.on("data", (chunk) => chunks.push(String(chunk))); - child.on("close", (code) => resolve({ code, output: chunks.join("") })); - }); -} diff --git a/gui/frontend/e2e/web-full-upload.spec.ts b/gui/frontend/e2e/web-full-upload.spec.ts deleted file mode 100644 index 1d6c914b..00000000 --- a/gui/frontend/e2e/web-full-upload.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { expect, test } from "@playwright/test"; -import { createE2EWorkspace, fetchMetadata, startApp, type AppServer } from "./helpers/e2eHarness"; - -test("embedded web runs image upload, tracker dry run, tracker upload, and history", async ({ - page, -}) => { - const workspace = await createE2EWorkspace(); - let app: AppServer | undefined; - try { - app = await startApp(workspace); - await fetchMetadata(page, app.url, workspace.sourcePath); - - await page.getByRole("button", { name: "Dupe Checking" }).click(); - await page.getByRole("button", { name: "Run dupe check" }).click(); - await expect(page.getByText("BTN").first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Run dupe check" })).toBeEnabled(); - - await page.getByRole("button", { name: "Screenshots" }).click(); - await page.getByRole("button", { name: "Generate screenshots" }).click(); - await expect( - page - .getByText( - /Generated screenshots ready for upload\.|All requested screenshots already exist\./, - ) - .first(), - ).toBeVisible(); - await page.getByRole("button", { name: "Upload Images" }).click(); - await expect(page.getByText("Available: 1")).toBeVisible(); - await page.getByRole("button", { name: "Upload 1" }).click(); - await expect.poll(() => workspace.fake.counters.imageUploads).toBe(1); - await expect(page.getByRole("link", { name: "Web URL" }).first()).toHaveAttribute( - "href", - /\/image\/1$/, - ); - - await page.getByRole("button", { name: "Description Builder" }).click(); - await page.getByRole("button", { name: "Refresh descriptions" }).click(); - await page.getByRole("button", { name: "Expand" }).click(); - await expect(page.locator("textarea").first()).toHaveValue(/E2E description fixture\./); - - await page.getByRole("button", { name: "Tracker Upload" }).click(); - await expect(page.getByRole("heading", { name: "Upload Targets" })).toBeVisible(); - await page.getByRole("button", { name: "Run Dry Run" }).click(); - await page.getByText("Dry run data").click(); - const dryRunDetails = page.locator("details[open]").filter({ hasText: "Dry run data" }).first(); - await expect(dryRunDetails.getByText("E2E.Movie.2026.1080p.WEB-DL").first()).toBeVisible(); - await expect(dryRunDetails.getByText("/upload").first()).toBeVisible(); - - await page.getByRole("button", { name: "Start Upload" }).click(); - await expect.poll(() => workspace.fake.counters.trackerUploads).toBe(1); - await expect(page.getByText(/Uploaded:\s*1/)).toBeVisible(); - - await page.getByRole("button", { name: "History" }).click(); - await expect(page.getByText("E2E.Movie.2026.1080p.WEB-DL").first()).toBeVisible(); - await expect(page.getByText("BTN").first()).toBeVisible(); - } finally { - await app?.stop(); - await workspace.cleanup(); - } -}); diff --git a/gui/frontend/e2e/web-smoke.spec.ts b/gui/frontend/e2e/web-smoke.spec.ts deleted file mode 100644 index 90a6093e..00000000 --- a/gui/frontend/e2e/web-smoke.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { expect, test } from "@playwright/test"; -import { createE2EWorkspace, startApp, type AppServer } from "./helpers/e2eHarness"; - -test("embedded web boots with dev auth, navigates core pages, and reports invalid paths", async ({ - page, -}) => { - const workspace = await createE2EWorkspace(); - let app: AppServer | undefined; - try { - app = await startApp(workspace); - await page.goto(app.url); - await expect(page.getByRole("heading", { name: "Build Release Name" })).toBeVisible(); - - await page.getByRole("button", { name: "Settings" }).click(); - await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); - await page.getByRole("button", { name: "Reload" }).click(); - await expect(page.getByText("Configuration")).toBeVisible(); - - await page.getByRole("button", { name: "Logging" }).click(); - await expect(page.getByRole("heading", { name: "Logging" })).toBeVisible(); - - await page.getByRole("button", { name: "History" }).click(); - await expect(page.getByRole("heading", { name: "History" })).toBeVisible(); - - await page.getByRole("button", { name: "Input" }).click(); - await page.getByLabel("Source path").fill("Z:\\missing\\e2e.mkv"); - await page.getByRole("button", { name: "Fetch metadata" }).click(); - await expect(page.locator(".error")).toContainText(/path|file|stat|missing/i); - } finally { - await app?.stop(); - await workspace.cleanup(); - } -}); diff --git a/gui/frontend/package.json b/gui/frontend/package.json deleted file mode 100644 index 761bbc4c..00000000 --- a/gui/frontend/package.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "upbrr-gui", - "private": true, - "version": "0.1.0", - "packageManager": "pnpm@10.27.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "build:bundle": "vite build", - "preview": "vite preview", - "test:unit": "vitest run", - "test:unit:watch": "vitest", - "test:e2e:web": "playwright test --project=web-smoke --project=web-base-path-smoke --project=web-full-upload", - "test:e2e:full": "playwright test", - "lint": "eslint --cache \"src/**/*.{ts,tsx}\"", - "lint:fix": "eslint --cache --fix src --ext .ts,.tsx", - "lint:style": "stylelint \"src/**/*.css\"", - "lint:style:fix": "stylelint \"src/**/*.css\" --fix", - "lint:dead": "knip --include files,exports,dependencies --no-config-hints", - "typecheck": "tsc --noEmit", - "format": "prettier --write --cache .", - "format:check": "prettier --check --cache ." - }, - "dependencies": { - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-switch": "^1.2.6", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@playwright/test": "^1.57.0", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.58.2", - "@typescript-eslint/parser": "^8.58.2", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "eslint": "^10.2.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-unused-imports": "^4.4.1", - "happy-dom": "^20.9.0", - "knip": "^6.4.1", - "postcss": "^8.5.15", - "prettier": "^3.8.2", - "stylelint": "^16.26.1", - "stylelint-config-recommended": "^17.0.0", - "tailwindcss": "^3.4.19", - "typescript": "^5.6.3", - "vite": "^8.0.16", - "vitest": "^4.1.8" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] - } -} diff --git a/gui/frontend/src/app.test.ts b/gui/frontend/src/app.test.ts deleted file mode 100644 index bdc2fd7c..00000000 --- a/gui/frontend/src/app.test.ts +++ /dev/null @@ -1,1378 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { createElement } from "react"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import App, { hasExplicitEmptyReleaseTrackerSelection, ruleBlockingTrackerLabels } from "./app"; -import type { - DescriptionBuilderPreview, - DVDMenuCaptureSnapshot, - DupeCheckResult, - DupeCheckSnapshot, - DupeEntry, - DupeMatch, - HistoryEntry, - HistoryOverview, - MetadataPreview, - ScreenshotPlan, - TrackerUploadSnapshot, -} from "./types"; -import { isRuntimePathCaseInsensitive, updateBrowserCSRFToken } from "./utils/runtime"; -import { hasFilteredEmptyUploadTrackerSelection } from "./utils/trackerSelection"; - -vi.mock("../wailsjs/runtime/runtime", () => ({ - EventsOn: vi.fn(() => () => undefined), - OnFileDrop: vi.fn(), - OnFileDropOff: vi.fn(), -})); - -const defaultPathCaseInsensitive = isRuntimePathCaseInsensitive(); - -afterEach(() => { - cleanup(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - updateBrowserCSRFToken("", defaultPathCaseInsensitive); - delete (globalThis as typeof globalThis & { go?: any }).go; -}); - -type FetchMetadata = ( - sourcePath: string, - sourceLookupURL: string, - overrides: unknown, - nameOverrides: unknown, - trackers: string[], -) => Promise; - -type ResetMetadata = FetchMetadata; -type SaveConfig = (config: string) => Promise; -type FetchScreenshotPlan = (sourcePath: string) => Promise; -type FetchDescriptionBuilder = ( - sourcePath: string, - overrides: unknown, - nameOverrides: unknown, - trackers: string[], - ignoreDupesFor: string[], -) => Promise; -type FetchPreparation = ( - sourcePath: string, - overrides: unknown, - nameOverrides: unknown, - trackers: string[], - ignoreDupesFor: string[], -) => Promise; -type DetectDiscType = (sourcePath: string) => Promise; -type StartDupeCheck = (...args: unknown[]) => Promise; -type GetDupeCheckSnapshot = (jobID: string) => Promise; -type StartTrackerUpload = (...args: unknown[]) => Promise; -type RetryFailedTrackerUpload = (jobID: string) => Promise; -type CancelTrackerUpload = (jobID: string) => Promise; -type GetTrackerUploadSnapshot = (jobID: string) => Promise; -type ListHistory = () => Promise; -type GetHistoryOverview = (sourcePath: string) => Promise; -type DeleteHistoryRelease = (sourcePath: string) => Promise; -type StartDVDMenuCapture = (...args: unknown[]) => Promise; -type GetDVDMenuCaptureSnapshot = (jobID: string) => Promise; - -const createDeferred = () => { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -}; - -const metadataPreview = (sourcePath: string): MetadataPreview => ({ - SourcePath: sourcePath, - TrackerName: "AITHER", - ReleaseName: "Example.Release.2026.1080p", - Warnings: [], - ReleaseNameOverrides: {}, - ExternalIDs: { - TMDBID: 1, - IMDBID: 0, - TVDBID: 0, - TVmazeID: 0, - MALID: 0, - Category: "movie", - SourceTMDB: "", - SourceIMDB: "", - SourceTVDB: "", - SourceTVmaze: "", - SourceMAL: "", - }, - ExternalIDCandidates: { - TMDB: [], - IMDB: [], - TMDBAutoSelected: false, - IMDBAutoSelected: false, - }, - ExternalIDInfo: [], - ExternalPreview: [], - TrackerData: [], -}); - -const screenshotPlan = (sourcePath: string): ScreenshotPlan => ({ - SourcePath: sourcePath, - DiscType: "", - DurationSeconds: 60, - FrameRate: 24, - SuggestedSelections: [], - ExistingScreenshots: [], - ExistingTrackerScreenshots: [], - FinalSelections: [], - TrackerImageLinks: [], - PreviewImages: [], - MetadataTimestamp: "", - RequiresManualFrames: false, -}); - -const descriptionBuilderPreview = (sourcePath: string): DescriptionBuilderPreview => ({ - SourcePath: sourcePath, - Groups: [], -}); - -const trackerUploadSnapshot = ( - jobID: string, - status: string, - overrides: Partial = {}, -): TrackerUploadSnapshot => ({ - jobID, - sourcePath: "C:\\media\\Example", - status, - currentTask: "", - currentTaskStatus: status, - currentMessage: "", - currentCompletedPieces: 0, - currentTotalPieces: 0, - currentPercent: 0, - currentHashRateMiB: 0, - trackers: [], - failedTrackers: [], - uploadedCount: status === "completed" ? 1 : 0, - error: "", - startedAt: "2026-06-17T00:00:00Z", - finishedAt: "", - ...overrides, -}); - -const emptyDupeMatch: DupeMatch = { - FilenameMatch: "", - FileCountMatch: 0, - SizeMatch: "", - TrumpableID: "", - MatchedID: "", - MatchedName: "", - MatchedLink: "", - MatchedDownload: "", - MatchedReason: "", - SeasonPackExists: false, - SeasonPackName: "", - SeasonPackLink: "", - SeasonPackID: "", - SeasonPackContainsEpisode: false, - MatchedEpisodeIDs: [], -}; - -const dupeEntry = (tracker: string): DupeEntry => ({ - Name: `${tracker} duplicate`, - SizeBytes: 0, - SizeKnown: false, - SizeText: "", - Files: [], - FileCount: 0, - Trumpable: false, - Link: "", - Download: "", - Flags: [], - ID: "", - Type: "", - Res: "", - Internal: false, - BDInfo: "", - Description: "", -}); - -const dupeResult = (tracker: string, hasDupes: boolean): DupeCheckResult => ({ - Tracker: tracker, - Raw: [], - Filtered: hasDupes ? [dupeEntry(tracker)] : [], - HasDupes: hasDupes, - ContentFail: false, - Match: emptyDupeMatch, - Notes: [], - Skipped: false, - SkipReason: "", - SkipCode: "", - SkipRules: [], - Status: "completed", - Error: "", - CheckedAt: "2026-06-17T00:00:00Z", -}); - -const dupeCheckSnapshot = (sourcePath = "C:\\media\\Example"): DupeCheckSnapshot => ({ - jobID: "dupe-job-1", - sourcePath, - status: "completed", - trackers: [], - completedCount: 2, - totalCount: 2, - summary: { - SourcePath: sourcePath, - Results: [dupeResult("AITHER", false), dupeResult("BLU", true)], - Notes: [], - }, - error: "", - startedAt: "2026-06-17T00:00:00Z", - finishedAt: "2026-06-17T00:00:01Z", -}); - -const dvdMenuCaptureSnapshot = (): DVDMenuCaptureSnapshot => ({ - jobID: "dvd-job-1", - sourcePath: "C:\\media\\Example", - status: "completed", - phase: "complete", - message: "DVD menu capture finished.", - discoveredMenus: 1, - visitedStates: 2, - visitedButtons: 1, - capturedCount: 1, - warningCount: 0, - result: { - SourcePath: "C:\\media\\Example", - Images: [], - SelectedLanguage: "en", - Region: 0, - DiscoveredMenus: 1, - VisitedStates: 2, - VisitedButtons: 1, - MaxItems: 6, - Complete: true, - Partial: false, - Truncated: false, - Warnings: [], - Engine: { - EngineVersion: "phase0a-1", - SchemaVersion: 1, - SupportedFeatures: [], - FFmpegVersion: "8.0", - FFmpegDVDVideo: true, - MissingFFmpegOptions: [], - }, - }, - error: "", - startedAt: "2026-07-10T00:00:00Z", - finishedAt: "2026-07-10T00:00:01Z", -}); - -const historyEntry = (sourcePath: string): HistoryEntry => ({ - SourcePath: sourcePath, - ReleaseTitle: "Example Release", - ReleaseSource: "WEB", - ReleaseResolution: "1080p", - MetadataUpdatedAt: "2026-06-17T00:00:00Z", - LatestUploadStatus: "", - LatestUploadAt: "", - RuleFailureCount: 0, -}); - -const historyOverview = (sourcePath: string): HistoryOverview => ({ - SourcePath: sourcePath, - ReleaseTitle: "Example Release", - ReleaseSource: "WEB", - ReleaseResolution: "1080p", - MetadataUpdatedAt: "2026-06-17T00:00:00Z", - LatestUploadStatus: "", - LatestUploadAt: "", - StatusLabel: "Stored", - Metadata: {}, - ExternalIDs: metadataPreview(sourcePath).ExternalIDs, - ExternalMetadata: {}, - ReleaseNameOverrides: {}, - DescriptionOverride: { - SourcePath: sourcePath, - GroupKey: "", - Description: "", - UpdatedAt: "", - }, - DescriptionOverrides: [], - PlaylistSelection: { - SourcePath: sourcePath, - SelectedPlaylists: [], - UseAll: false, - UpdatedAt: "", - }, - TrackerMetadata: [], - TrackerRuleFailures: [], - Screenshots: [], - FinalSelections: [], - UploadedImages: [], - UploadHistory: [], -}); - -const installAppBridge = ( - fetchMetadata: FetchMetadata, - options: { - resetMetadata?: ResetMetadata; - saveConfig?: SaveConfig; - fetchScreenshotPlan?: FetchScreenshotPlan; - fetchDescriptionBuilder?: FetchDescriptionBuilder; - fetchPreparation?: FetchPreparation; - browseFolder?: () => Promise; - startDupeCheck?: StartDupeCheck; - getDupeCheckSnapshot?: GetDupeCheckSnapshot; - detectDiscType?: DetectDiscType; - startTrackerUpload?: StartTrackerUpload; - retryFailedTrackerUpload?: RetryFailedTrackerUpload; - cancelTrackerUpload?: CancelTrackerUpload; - getTrackerUploadSnapshot?: GetTrackerUploadSnapshot; - listHistory?: ListHistory; - getHistoryOverview?: GetHistoryOverview; - deleteHistoryRelease?: DeleteHistoryRelease; - startDVDMenuCapture?: StartDVDMenuCapture; - getDVDMenuCaptureSnapshot?: GetDVDMenuCaptureSnapshot; - } = {}, -) => { - const storage = new Map(); - vi.stubGlobal("localStorage", { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - }); - vi.stubGlobal("matchMedia", () => ({ matches: true })); - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - MainSettings: { - UseFavicons: false, - }, - Trackers: { - DefaultTrackers: ["AITHER", "BLU"], - Trackers: { - AITHER: { APIKey: "configured" }, - BLU: { APIKey: "configured" }, - }, - }, - ScreenshotHandling: { - ProcessLimit: 1, - MaxMenuItems: 6, - }, - }), - BrowseFolder: options.browseFolder ?? (async () => "C:\\media\\Example\\BDMV"), - GetDefaultConfig: async () => JSON.stringify({ Trackers: { Trackers: {} } }), - FetchMetadata: fetchMetadata, - DetectDiscType: options.detectDiscType, - ResetMetadata: - options.resetMetadata ?? (async (sourcePath: string) => metadataPreview(sourcePath)), - SaveConfig: options.saveConfig ?? (async () => undefined), - FetchScreenshotPlan: - options.fetchScreenshotPlan ?? (async (sourcePath: string) => screenshotPlan(sourcePath)), - FetchDescriptionBuilder: - options.fetchDescriptionBuilder ?? - (async (sourcePath: string) => descriptionBuilderPreview(sourcePath)), - FetchPreparation: options.fetchPreparation ?? (async () => ({})), - ListUploadedImages: async () => [], - ListDVDMenuScreenshots: async () => [], - ReadScreenshotImage: async () => "data:image/png;base64,example", - StartDVDMenuCapture: options.startDVDMenuCapture ?? (async () => "dvd-job-1"), - GetDVDMenuCaptureSnapshot: - options.getDVDMenuCaptureSnapshot ?? (async () => dvdMenuCaptureSnapshot()), - CancelDVDMenuCapture: async () => undefined, - DeleteDVDMenuScreenshot: async () => undefined, - ImportMenuImages: async () => undefined, - DiscoverPlaylists: async () => [ - { - file: "00001.mpls", - duration: 7200, - size: 0, - score: 1, - items: [{ path: "00001.m2ts", size: 1024 }], - }, - ], - SavePlaylistSelection: async () => undefined, - StartDupeCheck: options.startDupeCheck ?? (async () => "dupe-job-1"), - GetDupeCheckSnapshot: options.getDupeCheckSnapshot ?? (async () => dupeCheckSnapshot()), - StartTrackerUpload: options.startTrackerUpload ?? (async () => "upload-job-1"), - RetryFailedTrackerUpload: options.retryFailedTrackerUpload ?? (async () => "upload-job-2"), - CancelTrackerUpload: options.cancelTrackerUpload ?? (async () => undefined), - GetTrackerUploadSnapshot: - options.getTrackerUploadSnapshot ?? - (async (jobID: string) => trackerUploadSnapshot(jobID, "completed")), - ListHistory: options.listHistory ?? (async () => []), - GetHistoryOverview: - options.getHistoryOverview ?? (async (sourcePath: string) => historyOverview(sourcePath)), - DeleteHistoryRelease: options.deleteHistoryRelease ?? (async () => undefined), - }, - }, - }; -}; - -const openTrackerUploadPage = async ( - fetchMetadata: FetchMetadata, - options: Parameters[1] = {}, -) => { - installAppBridge(fetchMetadata, options); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Description Builder" })); - await screen.findByRole("button", { name: "Tracker Upload" }); - fireEvent.click(screen.getByRole("button", { name: "Tracker Upload" })); - await screen.findByRole("heading", { name: "Upload Targets" }); -}; - -describe("hasExplicitEmptyReleaseTrackerSelection", () => { - it("does not treat pre-selection state as deselect-all", () => { - expect(hasExplicitEmptyReleaseTrackerSelection([], {})).toBe(false); - expect(hasExplicitEmptyReleaseTrackerSelection([{ name: "AITHER" }], {})).toBe(false); - }); - - it("treats initialized all-false tracker state as explicit empty selection", () => { - expect( - hasExplicitEmptyReleaseTrackerSelection([{ name: "AITHER" }, { name: "BLU" }], { - AITHER: false, - BLU: false, - }), - ).toBe(true); - }); - - it("keeps nonempty tracker selections available", () => { - expect( - hasExplicitEmptyReleaseTrackerSelection([{ name: "AITHER" }, { name: "BLU" }], { - AITHER: true, - BLU: false, - }), - ).toBe(false); - }); -}); - -describe("external ID edits", () => { - it("sends touched equal-value IDs plus edited and cleared MAL IDs", async () => { - const fetchMetadata = vi.fn(async (sourcePath, _lookup, overrides) => { - const idOverrides = overrides as { TMDBID?: number; MALID?: number }; - const result = metadataPreview(sourcePath); - return { - ...result, - ExternalIDs: { - ...result.ExternalIDs, - TMDBID: idOverrides.TMDBID ?? result.ExternalIDs.TMDBID, - MALID: idOverrides.MALID ?? result.ExternalIDs.MALID, - }, - }; - }); - installAppBridge(fetchMetadata); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - - fireEvent.click(screen.getByText("Edit Release Details")); - const tmdbInput = screen.getByLabelText("TMDB ID"); - fireEvent.change(tmdbInput, { target: { value: "2" } }); - fireEvent.change(tmdbInput, { target: { value: "1" } }); - const malInput = screen.getByLabelText("MAL ID"); - fireEvent.change(malInput, { target: { value: "5114" } }); - - const refreshButton = screen.getByRole("button", { name: "Refresh metadata" }); - await waitFor(() => expect(refreshButton).not.toBeDisabled()); - fireEvent.click(refreshButton); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(2)); - expect(fetchMetadata.mock.calls[1][2]).toEqual({ TMDBID: 1, MALID: 5114 }); - - await waitFor(() => expect(screen.getByLabelText("MAL ID")).toHaveValue("5114")); - fireEvent.change(screen.getByLabelText("MAL ID"), { target: { value: "" } }); - - await waitFor(() => expect(refreshButton).not.toBeDisabled()); - fireEvent.click(refreshButton); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(3)); - expect(fetchMetadata.mock.calls[2][2]).toEqual({ TMDBID: 1, MALID: 0 }); - }); -}); - -describe("ruleBlockingTrackerLabels", () => { - const labelsFor = (result: Partial & Pick) => - Array.from( - ruleBlockingTrackerLabels({ - ...dupeResult(result.Tracker, false), - Skipped: true, - ...result, - }), - ).sort(); - - it("blocks skipped trackers", () => { - expect(labelsFor({ Tracker: "CZT" })).toEqual(["czt"]); - expect(labelsFor({ Tracker: "HDB", SkipCode: "legacy_code" })).toEqual(["hdb"]); - }); - - it("blocks each split tracker label", () => { - expect(labelsFor({ Tracker: "CZT, HDB", SkipCode: "legacy_code" })).toEqual([ - "czt", - "czt, hdb", - "hdb", - ]); - expect(labelsFor({ Tracker: ", CZT,, ", SkipCode: "legacy_code" })).toEqual([", czt,,", "czt"]); - }); -}); - -describe("hasFilteredEmptyUploadTrackerSelection", () => { - const trackerUploadItems = [ - { name: "AITHER", config: {} }, - { name: "BLU", config: {} }, - ]; - - it("detects selected input trackers filtered out of upload eligibility", () => { - expect( - hasFilteredEmptyUploadTrackerSelection({ - trackerUploadItems, - releasePageTrackerSelection: { - AITHER: false, - BLU: true, - }, - uploadToggles: { AITHER: true, BLU: true }, - dupedTrackerSet: new Set(["blu"]), - skippedDupeTrackerSet: new Set(), - ruleSkippedTrackerSet: new Set(), - failedDupeTrackerSet: new Set(), - }), - ).toBe(true); - }); - - it("preserves missing-key startup and nonempty eligible selections", () => { - expect( - hasFilteredEmptyUploadTrackerSelection({ - trackerUploadItems, - releasePageTrackerSelection: {}, - uploadToggles: { AITHER: true, BLU: true }, - dupedTrackerSet: new Set(), - skippedDupeTrackerSet: new Set(), - ruleSkippedTrackerSet: new Set(), - failedDupeTrackerSet: new Set(), - }), - ).toBe(false); - expect( - hasFilteredEmptyUploadTrackerSelection({ - trackerUploadItems, - releasePageTrackerSelection: { - AITHER: true, - }, - uploadToggles: { AITHER: true, BLU: true }, - dupedTrackerSet: new Set(), - skippedDupeTrackerSet: new Set(), - ruleSkippedTrackerSet: new Set(), - failedDupeTrackerSet: new Set(), - }), - ).toBe(false); - }); - - it("does not treat disabled upload toggles as missing startup state", () => { - expect( - hasFilteredEmptyUploadTrackerSelection({ - trackerUploadItems, - releasePageTrackerSelection: { - AITHER: true, - BLU: false, - }, - uploadToggles: { AITHER: false, BLU: true }, - dupedTrackerSet: new Set(), - skippedDupeTrackerSet: new Set(), - ruleSkippedTrackerSet: new Set(), - failedDupeTrackerSet: new Set(), - }), - ).toBe(true); - }); -}); - -describe("metadata tracker payloads", () => { - it("gates Disc Menus by detected disc type", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata, { detectDiscType: async () => "" }); - - render(createElement(App)); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example.mkv" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledOnce()); - await screen.findByText("2/2"); - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - expect(screen.queryByRole("button", { name: "Menu Images" })).not.toBeInTheDocument(); - }); - - it("ignores a fetch disc detection after the source path changes", async () => { - const detection = createDeferred(); - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const detectDiscType = vi.fn(() => detection.promise); - installAppBridge(fetchMetadata, { detectDiscType }); - - render(createElement(App)); - const sourcePath = screen.getByLabelText("Source path"); - fireEvent.change(sourcePath, { target: { value: "C:\\media\\First" } }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(detectDiscType).toHaveBeenCalledWith("C:\\media\\First")); - - fireEvent.change(sourcePath, { target: { value: "C:\\media\\Second.mkv" } }); - await act(async () => detection.resolve("BDMV")); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledOnce()); - await screen.findByText("2/2"); - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - expect(screen.queryByRole("button", { name: "Menu Images" })).not.toBeInTheDocument(); - }); - - it("ignores an older browse disc detection after a newer path selection", async () => { - const firstDetection = createDeferred(); - const secondDetection = createDeferred(); - const firstPath = "C:\\media\\First"; - const secondPath = "C:\\media\\Second"; - const browseFolder = vi - .fn<() => Promise>() - .mockResolvedValueOnce(firstPath) - .mockResolvedValueOnce(secondPath); - const detectDiscType = vi.fn((sourcePath) => { - if (sourcePath === firstPath) { - return firstDetection.promise; - } - if (sourcePath === secondPath) { - return secondDetection.promise; - } - return Promise.resolve(""); - }); - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata, { browseFolder, detectDiscType }); - - render(createElement(App)); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example.mkv" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledOnce()); - await screen.findByText("2/2"); - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - fireEvent.click(screen.getByRole("button", { name: "Input" })); - - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - await waitFor(() => expect(detectDiscType).toHaveBeenCalledWith(firstPath)); - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - await waitFor(() => expect(detectDiscType).toHaveBeenCalledWith(secondPath)); - - await act(async () => secondDetection.resolve("")); - await act(async () => firstDetection.resolve("BDMV")); - - expect(screen.getByLabelText("Source path")).toHaveValue(secondPath); - expect(screen.queryByRole("button", { name: "Menu Images" })).not.toBeInTheDocument(); - }); - - it("invalidates Description Builder after DVD menu capture without auto-navigation", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchDescriptionBuilder = vi.fn(async (sourcePath) => - descriptionBuilderPreview(sourcePath), - ); - installAppBridge(fetchMetadata, { - detectDiscType: async () => "DVD", - fetchDescriptionBuilder, - startDVDMenuCapture: async () => "dvd-job-1", - getDVDMenuCaptureSnapshot: async () => dvdMenuCaptureSnapshot(), - }); - - render(createElement(App)); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledOnce()); - await screen.findByText("2/2"); - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Description Builder" })); - await waitFor(() => expect(fetchDescriptionBuilder).toHaveBeenCalledTimes(1)); - fireEvent.click(screen.getByRole("button", { name: "Menu Images" })); - fireEvent.click(screen.getByRole("button", { name: "Capture DVD menus" })); - expect(await screen.findByText("Captured 1 DVD menu image.")).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Menu Images" })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Description Builder" })); - await waitFor(() => expect(fetchDescriptionBuilder).toHaveBeenCalledTimes(2)); - }); - - it("excludes dupe-blocked upload targets from metadata fetches", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata); - - render(createElement(App)); - - const sourcePath = screen.getByLabelText("Source path"); - fireEvent.change(sourcePath, { target: { value: "C:\\media\\Example" } }); - - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(2)); - expect(fetchMetadata.mock.calls[1][4]).toEqual(["AITHER", "BLU"]); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(3)); - expect(fetchMetadata.mock.calls[2][4]).toEqual(["AITHER"]); - }); - - it("restores dupe-ignored trackers for description builder requests", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchDescriptionBuilder = vi.fn(async (sourcePath) => - descriptionBuilderPreview(sourcePath), - ); - installAppBridge(fetchMetadata, { fetchDescriptionBuilder }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("switch", { name: "Ignore dupes for BLU" })); - await waitFor(() => expect(screen.getByText("Available for upload: 2")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Description Builder" })); - - await waitFor(() => expect(fetchDescriptionBuilder).toHaveBeenCalledTimes(1)); - expect(fetchDescriptionBuilder.mock.calls[0][3]).toEqual(["AITHER", "BLU"]); - expect(fetchDescriptionBuilder.mock.calls[0][4]).toEqual(["blu"]); - }); - - it("does not apply dupe blocks from a previous path to metadata fetches", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Other" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(2)); - expect(fetchMetadata.mock.calls[1][0]).toBe("C:\\media\\Other"); - expect(fetchMetadata.mock.calls[1][4]).toEqual(["AITHER", "BLU"]); - }); - - it("keeps current upload disables when stale dupe state came from another path", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Other" }, - }); - await waitFor(() => - expect(screen.getByLabelText("Source path")).toHaveValue("C:\\media\\Other"), - ); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(2)); - expect(fetchMetadata.mock.calls[1][4]).toEqual(["AITHER", "BLU"]); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(await screen.findByRole("button", { name: "Description Builder" })); - fireEvent.click(await screen.findByRole("button", { name: "Tracker Upload" })); - await screen.findByRole("heading", { name: "Upload Targets" }); - const aitherUploadSwitch = screen.getByRole("switch", { name: "Enable upload for AITHER" }); - expect(aitherUploadSwitch).toHaveAttribute("aria-checked", "true"); - fireEvent.click(aitherUploadSwitch); - await waitFor(() => expect(aitherUploadSwitch).toHaveAttribute("aria-checked", "false")); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(3)); - expect(fetchMetadata.mock.calls[2][0]).toBe("C:\\media\\Other"); - expect(fetchMetadata.mock.calls[2][4]).toEqual(["BLU"]); - }); - - it("keeps input tracker selections after deleting the current history release", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const deletedPath = "C:\\media\\Example"; - const listHistory = vi - .fn() - .mockResolvedValueOnce([historyEntry(deletedPath)]) - .mockResolvedValue([]); - const deleteHistoryRelease = vi.fn(async () => undefined); - const confirm = vi.fn(() => true); - vi.stubGlobal("confirm", confirm); - installAppBridge(fetchMetadata, { - listHistory, - getHistoryOverview: async (sourcePath) => historyOverview(sourcePath), - deleteHistoryRelease, - }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: deletedPath }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(screen.getByRole("button", { name: "History" })); - await screen.findByRole("button", { name: /Example Release/ }); - fireEvent.click(await screen.findByRole("button", { name: "Remove from database" })); - await waitFor(() => expect(deleteHistoryRelease).toHaveBeenCalledWith(deletedPath)); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Other" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(2)); - expect(fetchMetadata.mock.calls[1][4]).toEqual(["AITHER", "BLU"]); - await screen.findByText("2/2"); - }); - - it("blocks metadata fetch when all selected trackers are filtered out", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - installAppBridge(fetchMetadata); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("checkbox", { name: "AITHER" })); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - - await waitFor(() => - expect( - screen.getByText("Select at least one tracker before fetching metadata."), - ).toBeInTheDocument(), - ); - expect(fetchMetadata).toHaveBeenCalledTimes(1); - }); - - it("uses structured SkipRules for upload eligibility", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchPreparation = vi.fn(async () => ({})); - installAppBridge(fetchMetadata, { - fetchPreparation, - getDupeCheckSnapshot: async () => ({ - ...dupeCheckSnapshot(), - summary: { - SourcePath: "C:\\media\\Example", - Results: [ - dupeResult("AITHER", false), - { - ...dupeResult("BLU", false), - Skipped: true, - Status: "skipped", - SkipRules: ["required_metadata"], - }, - ], - Notes: [], - }, - }), - }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - fireEvent.click(await screen.findByRole("button", { name: "Confirm Selection" })); - - await waitFor(() => expect(fetchPreparation).toHaveBeenCalledTimes(1)); - expect(fetchPreparation.mock.calls[0][3]).toEqual(["AITHER"]); - }); - - it("blocks metadata reset when all selected trackers are filtered out", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const resetMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const confirm = vi.fn(() => true); - installAppBridge(fetchMetadata, { resetMetadata }); - vi.stubGlobal("confirm", confirm); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("checkbox", { name: "AITHER" })); - fireEvent.click(screen.getByRole("button", { name: "Reset data + refresh" })); - - await waitFor(() => - expect( - screen.getByText("Select at least one tracker before resetting metadata."), - ).toBeInTheDocument(), - ); - expect(confirm).not.toHaveBeenCalled(); - expect(resetMetadata).not.toHaveBeenCalled(); - }); - - it("skips warm metadata fetch when all selected trackers are filtered out", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const saveConfig = vi.fn(async () => undefined); - installAppBridge(fetchMetadata, { saveConfig }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("checkbox", { name: "AITHER" })); - fireEvent.click(screen.getByRole("button", { name: "Screenshots" })); - fireEvent.click(screen.getByText("Screenshot settings")); - fireEvent.change(screen.getByLabelText("FFmpeg concurrency"), { target: { value: "2" } }); - fireEvent.click(screen.getByRole("button", { name: "Apply settings" })); - - await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1)); - expect(fetchMetadata).toHaveBeenCalledTimes(1); - }); - - it("uses upload-eligible trackers when preparing selected playlists", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchPreparation = vi.fn(async () => ({})); - installAppBridge(fetchMetadata, { fetchPreparation }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - - fireEvent.click(await screen.findByRole("button", { name: "Confirm Selection" })); - - await waitFor(() => expect(fetchPreparation).toHaveBeenCalledTimes(1)); - expect(fetchPreparation.mock.calls[0][3]).toEqual(["AITHER"]); - expect(fetchPreparation.mock.calls[0][4]).toEqual([]); - }); - - it("does not apply previous path dupe blocks when preparing selected playlists", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchPreparation = vi.fn(async () => ({})); - installAppBridge(fetchMetadata, { - browseFolder: async () => "C:\\media\\Other\\BDMV", - fetchPreparation, - }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - fireEvent.click(await screen.findByRole("button", { name: "Confirm Selection" })); - - await waitFor(() => expect(fetchPreparation).toHaveBeenCalledTimes(1)); - expect(fetchPreparation.mock.calls[0][3]).toEqual(["AITHER", "BLU"]); - expect(fetchPreparation.mock.calls[0][4]).toEqual([]); - }); - - it.each([ - { - name: "root to lowercase child", - dupeSourcePath: "C:\\media\\Example", - browsePath: "C:\\media\\Example\\bdmv", - }, - { - name: "lowercase child to root", - dupeSourcePath: "C:\\media\\Example\\bdmv", - browsePath: "C:\\media\\Example", - detectDiscType: async () => "BDMV", - }, - { - name: "trailing slash root to lowercase child", - dupeSourcePath: "C:\\media\\Example\\", - browsePath: "C:\\media\\Example\\bdmv\\", - }, - ])("matches lowercase BDMV context for playlist preparation: $name", async (testCase) => { - updateBrowserCSRFToken("", false); - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const fetchPreparation = vi.fn(async () => ({})); - installAppBridge(fetchMetadata, { - browseFolder: async () => testCase.browsePath, - getDupeCheckSnapshot: async () => dupeCheckSnapshot(testCase.dupeSourcePath), - detectDiscType: testCase.detectDiscType, - fetchPreparation, - }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: testCase.dupeSourcePath }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Input" })); - fireEvent.click(screen.getByRole("button", { name: "Browse folder" })); - fireEvent.click(await screen.findByRole("button", { name: "Confirm Selection" })); - - await waitFor(() => expect(fetchPreparation).toHaveBeenCalledTimes(1)); - expect(fetchPreparation.mock.calls[0][3]).toEqual(["AITHER"]); - expect(fetchPreparation.mock.calls[0][4]).toEqual([]); - }); -}); - -describe("dupe check job tracking", () => { - it("honors fresh upload tracker toggles when starting dupe checks", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startDupeCheck = vi.fn(async () => "dupe-job-1"); - installAppBridge(fetchMetadata, { startDupeCheck }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - const bluTrackerCheckbox = screen.getByRole("checkbox", { name: "BLU" }); - expect(bluTrackerCheckbox).toHaveAttribute("aria-checked", "true"); - fireEvent.click(bluTrackerCheckbox); - await waitFor(() => expect(bluTrackerCheckbox).toHaveAttribute("aria-checked", "false")); - - fireEvent.click(screen.getByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(startDupeCheck).toHaveBeenCalledTimes(1)); - - expect(startDupeCheck.mock.calls[0][3]).toEqual(["AITHER"]); - }); - - it("reruns dupe checks without filtering previous dupe results", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startDupeCheck = vi.fn(async () => "dupe-job-1"); - installAppBridge(fetchMetadata, { startDupeCheck }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(startDupeCheck).toHaveBeenCalledTimes(2)); - - expect(startDupeCheck.mock.calls[1][3]).toEqual(["AITHER", "BLU"]); - }); - - it("keeps previous dupe results when job creation fails", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startDupeCheck = vi - .fn() - .mockResolvedValueOnce("dupe-job-1") - .mockRejectedValueOnce(new Error("dupe check requires metadata preview")); - installAppBridge(fetchMetadata, { startDupeCheck }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - await waitFor(() => expect(screen.getByText("1 blocked.")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - - await waitFor(() => expect(startDupeCheck).toHaveBeenCalledTimes(2)); - await screen.findByText("Fetch metadata first to cache a preview before checking dupes."); - expect(screen.getByText("1 blocked.")).toBeInTheDocument(); - }); - - it("shows metadata preview guidance when dupe job creation is blocked", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startDupeCheck = vi.fn(async () => { - throw new Error("dupe check requires metadata preview"); - }); - installAppBridge(fetchMetadata, { startDupeCheck }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - - await waitFor(() => expect(startDupeCheck).toHaveBeenCalledTimes(1)); - await screen.findByText("Fetch metadata first to cache a preview before checking dupes."); - }); - - it("shows metadata preview guidance when an existing dupe job reports a cache miss", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const getDupeCheckSnapshot = vi.fn(async () => ({ - ...dupeCheckSnapshot(), - status: "failed", - summary: { SourcePath: "C:\\media\\Example", Results: [], Notes: [] }, - error: "core: dupe check requires metadata preview", - finishedAt: "2026-06-17T00:00:01Z", - })); - installAppBridge(fetchMetadata, { getDupeCheckSnapshot }); - - render(createElement(App)); - - fireEvent.change(screen.getByLabelText("Source path"), { - target: { value: "C:\\media\\Example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); - await waitFor(() => expect(fetchMetadata).toHaveBeenCalledTimes(1)); - await screen.findByText("2/2"); - - fireEvent.click(await screen.findByRole("button", { name: "Dupe Checking" })); - fireEvent.click(screen.getByRole("button", { name: "Run dupe check" })); - - await waitFor(() => expect(getDupeCheckSnapshot).toHaveBeenCalledWith("dupe-job-1")); - await screen.findByText("Fetch metadata first to cache a preview before checking dupes."); - }); -}); - -describe("tracker upload job tracking", () => { - it("keeps start upload tracking alive when bootstrap snapshot loading fails", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startTrackerUpload = vi.fn(async () => "upload-job-1"); - const getTrackerUploadSnapshot = vi - .fn() - .mockRejectedValueOnce(new Error("bootstrap failed")) - .mockResolvedValueOnce(trackerUploadSnapshot("upload-job-1", "running")); - await openTrackerUploadPage(fetchMetadata, { startTrackerUpload, getTrackerUploadSnapshot }); - - fireEvent.click(screen.getByRole("button", { name: "Start Upload" })); - - await waitFor(() => expect(startTrackerUpload).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(getTrackerUploadSnapshot).toHaveBeenCalledWith("upload-job-1")); - await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled()); - await waitFor(() => expect(getTrackerUploadSnapshot).toHaveBeenCalledTimes(2), { - timeout: 1500, - }); - expect(getTrackerUploadSnapshot.mock.calls[1][0]).toBe("upload-job-1"); - }); - - it("preserves start upload creation failures", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startTrackerUpload = vi.fn(async () => { - throw new Error("start failed"); - }); - const getTrackerUploadSnapshot = vi.fn(); - await openTrackerUploadPage(fetchMetadata, { startTrackerUpload, getTrackerUploadSnapshot }); - - fireEvent.click(screen.getByRole("button", { name: "Start Upload" })); - - await waitFor(() => expect(screen.getByText("Error: start failed")).toBeInTheDocument()); - expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); - expect(getTrackerUploadSnapshot).not.toHaveBeenCalled(); - }); - - it("keeps retry upload tracking alive when replacement bootstrap snapshot loading fails", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startTrackerUpload = vi.fn(async () => "upload-job-1"); - const retryFailedTrackerUpload = vi.fn(async () => "upload-job-2"); - const getTrackerUploadSnapshot = vi - .fn() - .mockResolvedValueOnce( - trackerUploadSnapshot("upload-job-1", "failed", { - failedTrackers: ["AITHER"], - error: "upload failed", - finishedAt: "2026-06-17T00:00:01Z", - }), - ) - .mockRejectedValueOnce(new Error("retry bootstrap failed")) - .mockResolvedValueOnce(trackerUploadSnapshot("upload-job-2", "running")); - await openTrackerUploadPage(fetchMetadata, { - startTrackerUpload, - retryFailedTrackerUpload, - getTrackerUploadSnapshot, - }); - fireEvent.click(screen.getByRole("button", { name: "Start Upload" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Retry Failed" })).toBeEnabled()); - - fireEvent.click(screen.getByRole("button", { name: "Retry Failed" })); - - await waitFor(() => expect(retryFailedTrackerUpload).toHaveBeenCalledWith("upload-job-1")); - await waitFor(() => expect(getTrackerUploadSnapshot).toHaveBeenCalledWith("upload-job-2")); - await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled()); - await waitFor(() => expect(getTrackerUploadSnapshot).toHaveBeenCalledTimes(3), { - timeout: 1500, - }); - expect(getTrackerUploadSnapshot.mock.calls[2][0]).toBe("upload-job-2"); - }); - - it("preserves retry creation failures", async () => { - const fetchMetadata = vi.fn(async (sourcePath) => metadataPreview(sourcePath)); - const startTrackerUpload = vi.fn(async () => "upload-job-1"); - const retryFailedTrackerUpload = vi.fn(async () => { - throw new Error("retry failed"); - }); - const getTrackerUploadSnapshot = vi.fn().mockResolvedValueOnce( - trackerUploadSnapshot("upload-job-1", "failed", { - failedTrackers: ["AITHER"], - error: "upload failed", - finishedAt: "2026-06-17T00:00:01Z", - }), - ); - await openTrackerUploadPage(fetchMetadata, { - startTrackerUpload, - retryFailedTrackerUpload, - getTrackerUploadSnapshot, - }); - fireEvent.click(screen.getByRole("button", { name: "Start Upload" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Retry Failed" })).toBeEnabled()); - - fireEvent.click(screen.getByRole("button", { name: "Retry Failed" })); - - await waitFor(() => expect(screen.getByText("Error: retry failed")).toBeInTheDocument()); - expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); - expect(getTrackerUploadSnapshot).toHaveBeenCalledTimes(1); - }); -}); diff --git a/gui/frontend/src/app.tsx b/gui/frontend/src/app.tsx deleted file mode 100644 index 563c028c..00000000 --- a/gui/frontend/src/app.tsx +++ /dev/null @@ -1,4822 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import type { KeyboardEvent as ReactKeyboardEvent } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import * as Dialog from "@radix-ui/react-dialog"; -import { OnFileDrop, OnFileDropOff } from "../wailsjs/runtime/runtime"; -import { - EventsOn, - isBrowserMode, - isBrowserNativeBrowseAvailable, - isRuntimePathCaseInsensitive, - subscribeBrowserNativeBrowseAvailability, -} from "./utils/runtime"; -import DescriptionBuilderPage from "./pages/description_builder"; -import BlurayCandidatesPage from "./pages/bluray_candidates"; -import DupeCheckPage from "./pages/dupe_check"; -import InputPage from "./pages/input"; -import HistoryPage from "./pages/history/index"; -import LoggingPage from "./pages/logging"; -import PlaylistSelectionPage from "./pages/playlist_selection"; -import ScreenshotsPage from "./pages/screenshots"; -import MenuImagesPage from "./pages/menu_images"; -import SettingsPage from "./pages/settings"; -import TrackerDataPage from "./pages/tracker_data"; -import TrackerUploadPage from "./pages/tracker_upload"; -import UploadImagesPage from "./pages/upload_images"; -import { useSettingsState } from "./hooks/useSettingsState"; -import { useScreenshots } from "./hooks/useScreenshots"; -import { useUploadImages } from "./hooks/useUploadImages"; -import { useTrackerIcons } from "./hooks/useTrackerIcons"; -import { cn } from "./utils/cn"; -import type { - ConfigMap, - ApplicationInfo, - BrowseDirectoryResponse, - DescriptionBuilderPreview, - DVDMenuCaptureSnapshot, - DupeCheckResult, - DupeCheckSnapshot, - DupeCheckSummary, - ExternalIDInfo, - ExternalIDOverrides, - ExternalIDs, - HistoryEntry, - HistoryOverview, - ImageHostPolicyMetadata, - MetadataPreview, - MetadataProgressUpdate, - PreparationPreview, - ReleaseNameEditState, - ReleaseNameOverrides, - ReleaseNameTouchedState, - ScreenshotImage, - ScreenshotPlan, - ScreenshotPreviewImage, - ScreenshotPurpose, - ScreenshotResult, - ScreenshotSelection, - TrackerQuestionnaire, - TrackerAuthCapability, - TrackerAuthLoginRequest, - TrackerAuthStatus, - TrackerDryRunPreview, - TrackerUploadSnapshot, - WebAuthStatus, - UploadedImageLink, - UploadImagesResult, - UploadProgressUpdate, -} from "./types"; -import { - formatLabel, - isSkipAutoTorrentEnabled, - normalizeDefaultTrackerList, -} from "./utils/settings"; -import { - addSourcePathHistoryEntry, - defaultInputHistoryLimit, - filterBrowseEntries, - inferSourcePathMode, - normalizeSourcePathHistory, - resolveInputHistoryLimit, - sameSourcePath, - type SourcePathHistoryEntry, - type SourcePathMode, - sourcePathHistoryStorageKey, -} from "./utils/inputHistory"; -import { hasFetchedExternalPreviewData } from "./utils/externalPreview"; -import { handleExternalLinkClick } from "./utils/externalLinks"; -import { normalizeJobStatus } from "./utils/jobStatus"; -import { isMetadataProgressPathMatch } from "./utils/metadataProgress"; -import { dupeSkipReason, isRuleSkippedResult } from "./utils/dupeCheck"; -import { - hasFilteredEmptyUploadTrackerSelection as hasFilteredEmptyUploadTrackerSelectionState, - resolveSelectedUploadTrackers, -} from "./utils/trackerSelection"; - -const appLayoutClass = - "relative z-[1] block min-h-screen ml-[204px] max-[960px]:ml-0 max-[960px]:pb-[78px]"; - -const sidebarClass = - "fixed left-0 top-0 z-[1000] flex h-screen w-[204px] flex-col gap-2.5 border-r border-white/10 bg-[var(--panel)]/95 p-2.5 backdrop-blur max-[960px]:bottom-0 max-[960px]:top-auto max-[960px]:h-auto max-[960px]:w-full max-[960px]:flex-row max-[960px]:items-center max-[960px]:gap-2 max-[960px]:border-r-0 max-[960px]:border-t max-[960px]:p-2"; - -const sidebarGroupClass = - "grid gap-1 rounded-lg border border-[rgba(148,163,184,0.18)] bg-[rgba(148,163,184,0.08)] p-1.5 max-[960px]:flex max-[960px]:flex-wrap max-[960px]:gap-1 max-[960px]:p-1"; - -const sidebarFooterClass = `${sidebarGroupClass} mt-auto max-[960px]:mt-0`; - -const navButtonClass = (active: boolean, nested = false) => - cn( - "w-full rounded-md border border-transparent bg-transparent px-2 py-1.5 text-left text-[0.84rem] font-semibold leading-tight text-[var(--muted)] transition hover:bg-white/10 hover:text-[var(--text)] max-[960px]:w-auto max-[960px]:py-1.5", - nested && "pl-4 text-[0.8rem] font-medium max-[960px]:pl-2", - active && - "border-[var(--sidebar-active-border)] bg-[var(--sidebar-active-bg)] text-[var(--sidebar-active-text)] shadow-[0_8px_22px_rgba(245,185,66,0.18)] hover:bg-[var(--sidebar-active-bg)] hover:text-[var(--sidebar-active-text)]", - ); - -const sidebarButtonClass = (active = false) => - cn( - "flex min-h-[30px] w-full items-center justify-start gap-1.5 rounded-md border border-transparent bg-transparent px-2 py-1.5 text-[0.84rem] font-semibold leading-tight text-[var(--muted)] transition hover:bg-white/10 hover:text-[var(--text)] max-[960px]:w-auto max-[960px]:min-h-7 max-[960px]:py-1", - active && - "border-[var(--sidebar-active-border)] bg-[var(--sidebar-active-bg)] text-[var(--sidebar-active-text)] shadow-[0_8px_22px_rgba(245,185,66,0.18)] hover:bg-[var(--sidebar-active-bg)] hover:text-[var(--sidebar-active-text)]", - ); - -const sidebarAppDetailsClass = - "mt-1 grid grid-cols-[1fr_auto] items-center gap-1.5 px-2 py-1.5 text-[0.72rem] leading-tight text-[var(--muted)] max-[960px]:hidden"; - -type AppBridgeWithApplicationInfo = { - GetApplicationInfo?: () => Promise; -}; - -const emptyDupeSummary: DupeCheckSummary = { - SourcePath: "", - Results: [], - Notes: [], -}; - -const emptyPreview: MetadataPreview = { - SourcePath: "", - TrackerName: "", - ReleaseName: "", - Warnings: [], - ReleaseNameOverrides: {}, - ExternalIDs: { - TMDBID: 0, - IMDBID: 0, - TVDBID: 0, - TVmazeID: 0, - MALID: 0, - Category: "", - SourceTMDB: "", - SourceIMDB: "", - SourceTVDB: "", - SourceTVmaze: "", - SourceMAL: "", - }, - ExternalIDCandidates: { - TMDB: [], - IMDB: [], - TMDBAutoSelected: false, - IMDBAutoSelected: false, - }, - ExternalIDInfo: [], - ExternalPreview: [], - Bluray: undefined, - TrackerData: [], - TrackerRuleFailures: {}, -}; - -const emptyTrackerDryRun: TrackerDryRunPreview = { - SourcePath: "", - Trackers: [], -}; - -const cloneQuestionnaireAnswers = (input: Record>) => - Object.fromEntries( - Object.entries(input).map(([tracker, values]) => [ - tracker, - Object.fromEntries( - Object.entries(values || {}).map(([key, value]) => [key, String(value ?? "")]), - ), - ]), - ); - -const buildQuestionnaireAnswerDefaults = ( - questionnaire: TrackerQuestionnaire | null | undefined, - existing: Record | undefined, -) => { - const next: Record = { ...(existing || {}) }; - (questionnaire?.Fields || []).forEach((field) => { - if (next[field.Key] === undefined) { - next[field.Key] = field.Value || ""; - } - }); - return next; -}; - -const splitTrackerLabel = (value: string) => - value - .split(",") - .map((entry) => entry.toLowerCase().trim()) - .filter((entry) => entry.length > 0); - -/** - * Returns every normalized tracker label represented by a duplicate-check row. - * Grouped rule rows use comma-separated tracker labels, while ordinary rows use - * a single tracker name; callers choose whether the row is a rule skip, generic - * skip, dupe hit, or failure before applying the labels. - */ -export const ruleBlockingTrackerLabels = (result: Pick) => { - const next = new Set(); - const normalized = result.Tracker.toLowerCase().trim(); - const splitLabels = splitTrackerLabel(result.Tracker); - - if (normalized) { - next.add(normalized); - } - splitLabels.forEach((tracker) => next.add(tracker)); - return next; -}; - -const isFinishedDupeTrackerStatus = (status: string) => { - switch (status.toLowerCase().trim()) { - case "complete": - case "completed": - case "skipped": - case "failed": - case "canceled": - return true; - default: - return false; - } -}; - -/** - * Converts a finished per-tracker job state into the same result shape used by - * grouped summaries. Queued/running states without a concrete result are ignored - * so upload eligibility is derived only from completed tracker outcomes. - */ -const dupeResultFromTrackerState = ( - state: DupeCheckSnapshot["trackers"][number], -): DupeCheckResult | null => { - const resultTracker = String(state.result?.Tracker || "").trim(); - const status = String(state.result?.Status || state.status || "").trim(); - if (!resultTracker && !isFinishedDupeTrackerStatus(status)) return null; - const tracker = String(resultTracker || state.tracker || "").trim(); - if (!tracker) return null; - return { - ...state.result, - Tracker: tracker, - Status: status, - }; -}; - -/** Returns per-tracker dupe results from a snapshot, excluding unfinished placeholders. */ -const dupeResultsFromSnapshot = (snapshot: DupeCheckSnapshot | null) => - (snapshot?.trackers || []) - .map(dupeResultFromTrackerState) - .filter((result): result is DupeCheckResult => Boolean(result)); - -/** Maps backend metadata-cache misses to the same actionable dupe-check guidance. */ -const dupeCheckErrorMessage = (message: string) => - message.includes("dupe check requires metadata preview") - ? "Fetch metadata first to cache a preview before checking dupes." - : message; - -const emptyDescriptionBuilder: DescriptionBuilderPreview = { - SourcePath: "", - Groups: [], -}; - -/** - * Returns true when every available release tracker has an explicit false selection. - * - * Missing tracker keys are treated as uninitialized state, not user deselection. - */ -export const hasExplicitEmptyReleaseTrackerSelection = ( - trackerUploadItems: Array<{ name: string }>, - releasePageTrackerSelection: Record, -) => { - if (trackerUploadItems.length === 0) { - return false; - } - return trackerUploadItems.every( - (item) => - Object.prototype.hasOwnProperty.call(releasePageTrackerSelection, item.name) && - !releasePageTrackerSelection[item.name], - ); -}; - -const hasOwnSelection = (value: Record, key: string) => - Object.prototype.hasOwnProperty.call(value, key); - -/** - * Normalizes source paths for lightweight context comparisons. - * - * This keeps host path identity checks independent of slash direction and - * trailing separators without replacing the runtime-aware same-path helper. - */ -const normalizePathContext = (value: string, caseInsensitive: boolean) => { - let normalized = value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); - if (caseInsensitive) { - normalized = normalized.toLowerCase(); - } - return normalized; -}; - -const normalizeBdmvPathContext = (value: string, caseInsensitive: boolean) => - normalizePathContext(value, caseInsensitive).replace(/(^|\/)bdmv(?=\/|$)/gi, "$1BDMV"); - -/** - * Returns true when two source paths refer to the same eligibility context. - * - * A selected Blu-ray folder may be represented by either the release root or - * its BDMV child folder; both must share dupe/rule tracker eligibility. - */ -const isSourcePathContextMatch = (left: string, right: string, caseInsensitive: boolean) => { - if (sameSourcePath(left, right, caseInsensitive)) { - return true; - } - const normalizedLeft = normalizeBdmvPathContext(left, caseInsensitive); - const normalizedRight = normalizeBdmvPathContext(right, caseInsensitive); - if (!normalizedLeft || !normalizedRight) { - return false; - } - return ( - normalizedLeft === `${normalizedRight}/BDMV` || normalizedRight === `${normalizedLeft}/BDMV` - ); -}; - -const upsertBuilderGroup = ( - preview: DescriptionBuilderPreview, - nextGroup: DescriptionBuilderPreview["Groups"][number], -): DescriptionBuilderPreview => { - const nextGroups = [...(preview.Groups || [])]; - const existingIndex = nextGroups.findIndex((group) => group.GroupKey === nextGroup.GroupKey); - if (existingIndex >= 0) { - nextGroups[existingIndex] = nextGroup; - } else { - nextGroups.push(nextGroup); - } - return { - ...preview, - Groups: nextGroups, - }; -}; - -const bdinfoProgressEvent = "bdinfo:progress"; -const metadataProgressEvent = "metadata:progress"; -const dupeCheckEventPrefix = "dupe:job:"; -const trackerUploadEventPrefix = "upload:job:"; -const trackerUploadProgressEvent = "upload:progress"; -const runLogLevels = ["error", "warn", "info", "debug", "trace"] as const; - -type SourcePathSelection = { - path: string; - mode: SourcePathMode; - waitsForPlaylistSelection: boolean; -}; - -const progressUpdatePrefixes = new Set([ - "scanning", - "initialize", - "playlist", - "clipinfo", - "stream", -]); - -const progressLineKey = (line: string): string | null => { - const match = /^([A-Za-z_]+):\s+/.exec(line); - if (!match) { - return null; - } - const key = match[1].toLowerCase(); - return progressUpdatePrefixes.has(key) ? key : null; -}; - -const appendBoundedProgressLine = (lines: string[], line: string) => { - if (lines.length >= 300) { - return [...lines.slice(-299), line]; - } - return [...lines, line]; -}; - -const upsertProgressLine = (lines: string[], line: string) => { - const key = progressLineKey(line); - if (!key) { - return appendBoundedProgressLine(lines, line); - } - - const existingIndex = lines.findIndex((existing) => progressLineKey(existing) === key); - if (existingIndex < 0) { - return appendBoundedProgressLine(lines, line); - } - - if (lines[existingIndex] === line) { - return lines; - } - - const next = [...lines]; - next[existingIndex] = line; - return next; -}; - -/** Returns whether a background job status should keep progress recovery active. */ -const isRunningJobStatus = (status: string) => { - const normalized = normalizeJobStatus(status); - return normalized === "queued" || normalized === "running"; -}; - -declare global { - var go: - | { - guiapp?: { - App?: { - BrowsePath: () => Promise; - BrowseFile: () => Promise; - BrowseFiles: () => Promise; - BrowseImageFiles: () => Promise; - BrowseFolder: () => Promise; - BrowseDirectory: ( - path: string, - mode: "file" | "folder", - ) => Promise; - OpenExternalURL?: (url: string) => Promise; - DetectDiscType: (path: string) => Promise; - FetchMetadata: ( - path: string, - sourceLookupURL: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ) => Promise; - ResetMetadata: ( - path: string, - sourceLookupURL: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ) => Promise; - SelectBlurayCandidate: (path: string, releaseID: string) => Promise; - FetchDescriptionBuilder: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ignoreDupesFor: string[], - ) => Promise; - FetchPreparation: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ignoreDupesFor: string[], - ) => Promise; - FetchTrackerDryRun: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ignoreDupesFor: string[], - questionnaireAnswers: Record>, - descriptionGroups: DescriptionBuilderPreview["Groups"], - debug: boolean, - noSeed: boolean, - runLogLevel: string, - ) => Promise; - CheckDupes: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ) => Promise; - StartDupeCheck: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ) => Promise; - CancelDupeCheck: (jobID: string) => Promise; - GetDupeCheckSnapshot: (jobID: string) => Promise; - FetchScreenshotPlan: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - GenerateScreenshots: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - selections: ScreenshotSelection[], - purpose: ScreenshotPurpose, - ) => Promise; - PreviewScreenshotFrame: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - timestampSeconds: number, - ) => Promise; - DeleteScreenshot: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - imagePath: string, - ) => Promise; - SaveFinalScreenshotSelections: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - images: ScreenshotImage[], - ) => Promise; - ImportMenuImages: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - paths: string[], - ) => Promise; - /** Starts background capture from prepared metadata and resolves to an opaque job ID. */ - StartDVDMenuCapture: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - /** Returns the latest state for a background DVD menu capture job. */ - GetDVDMenuCaptureSnapshot: (jobID: string) => Promise; - /** Requests asynchronous cancellation of a background DVD menu capture job. */ - CancelDVDMenuCapture: (jobID: string) => Promise; - /** Lists persisted manual and automatic menu images for a prepared source path. */ - ListDVDMenuScreenshots: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - /** Removes one managed menu image and its local records. */ - DeleteDVDMenuScreenshot: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - imagePath: string, - ) => Promise; - ReadScreenshotImage: (path: string) => Promise; - ListUploadCandidates: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - ListUploadedImages: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - UploadImages: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - host: string, - images: ScreenshotImage[], - ) => Promise; - DeleteUploadedImage: (path: string, imagePath: string, host: string) => Promise; - RenderDescription: (raw: string) => Promise; - SaveDescriptionOverride: ( - path: string, - groupKey: string, - raw: string, - trackers: string[], - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => Promise; - DiscoverPlaylists: (path: string) => Promise; - SavePlaylistSelection: ( - path: string, - playlists: string[], - useAll: boolean, - ) => Promise; - LoadPlaylistSelection: (path: string) => Promise; - GetConfig: () => Promise; - GetDefaultConfig: () => Promise; - GetWebAuthStatus: () => Promise; - CreateWebAuth: (username: string, password: string) => Promise; - SaveConfig: (payload: string) => Promise; - ExportConfig: () => Promise; - ImportConfig: () => Promise<{ message: string; warnings: string[] }>; - GetLogPath: () => Promise; - GetRecentLogs: (limit: number) => Promise; - StartLogStream: () => Promise; - StopLogStream: (streamID: string) => Promise; - GetLogExclusions: () => Promise; - UpdateLogExclusions: (patterns: string[]) => Promise; - ListKnownTrackers: () => Promise; - ListTrackerAuthCapabilities?: () => Promise; - GetTrackerAuthStatus?: (tracker: string) => Promise; - ImportTrackerAuthCookies?: (tracker: string) => Promise; - ImportTrackerAuthCookieContent?: ( - tracker: string, - fileName: string, - content: string, - ) => Promise; - TestTrackerAuth?: (tracker: string) => Promise; - LoginTrackerAuth?: ( - tracker: string, - req: TrackerAuthLoginRequest, - ) => Promise; - SubmitTrackerAuth2FA?: ( - challengeID: string, - code: string, - ) => Promise; - DeleteTrackerAuth?: (tracker: string) => Promise; - GetImageHostPolicyMetadata: () => Promise; - ListHistory: () => Promise; - GetHistoryOverview: (sourcePath: string) => Promise; - DeleteHistoryRelease: (sourcePath: string) => Promise; - StartTrackerUpload: ( - path: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - trackers: string[], - ignoreDupesFor: string[], - questionnaireAnswers: Record>, - descriptionGroups: DescriptionBuilderPreview["Groups"], - debug: boolean, - noSeed: boolean, - runLogLevel: string, - ) => Promise; - CancelTrackerUpload: (jobID: string) => Promise; - RetryFailedTrackerUpload: (jobID: string) => Promise; - GetTrackerUploadSnapshot: (jobID: string) => Promise; - GetTrackerIcon?: (domain: string, customURL: string) => Promise; - }; - }; - } - | undefined; -} - -const parseIDInput = (provider: string, value: string) => { - const trimmed = value.trim(); - if (!trimmed) return 0; - let normalized = trimmed; - if (provider === "imdb" && /^tt/i.test(trimmed)) { - normalized = trimmed.slice(2); - } - if (!/^\d+$/.test(normalized)) return null; - return Number(normalized); -}; - -const providerOrder = ["tmdb", "imdb", "tvdb", "tvmaze", "mal"] as const; - -const filterAndOrderExternalIDs = (info: ExternalIDInfo[]) => { - const orderIndex = new Map( - providerOrder.map((provider, index) => [provider, index]), - ); - - return [...info].sort((left, right) => { - const leftIndex = orderIndex.get(left.Provider) ?? providerOrder.length; - const rightIndex = orderIndex.get(right.Provider) ?? providerOrder.length; - if (leftIndex !== rightIndex) return leftIndex - rightIndex; - return left.Provider.localeCompare(right.Provider); - }); -}; - -const formatNumber = (value: number) => (value ? value.toString() : ""); - -const buildIDEditState = (ids: ExternalIDs) => ({ - tmdb: formatNumber(ids.TMDBID), - imdb: formatNumber(ids.IMDBID), - tvdb: formatNumber(ids.TVDBID), - tvmaze: formatNumber(ids.TVmazeID), - mal: formatNumber(ids.MALID), -}); - -type IDProviderKey = keyof ReturnType; - -const buildIDTouchedState = (): Record => ({ - tmdb: false, - imdb: false, - tvdb: false, - tvmaze: false, - mal: false, -}); - -const buildReleaseEditState = (overrides?: ReleaseNameOverrides): ReleaseNameEditState => ({ - category: overrides?.Category ?? "", - type: overrides?.Type ?? "", - source: overrides?.Source ?? "", - resolution: overrides?.Resolution ?? "", - tag: overrides?.Tag ?? "", - service: overrides?.Service ?? "", - edition: overrides?.Edition ?? "", - season: overrides?.Season ?? "", - episode: overrides?.Episode ?? "", - episodeTitle: overrides?.EpisodeTitle ?? "", - manualYear: overrides?.ManualYear ? overrides.ManualYear.toString() : "", - manualDate: overrides?.ManualDate ?? "", - useSeasonEpisode: Boolean(overrides?.UseSeasonEpisode), - noSeason: Boolean(overrides?.NoSeason), - noYear: Boolean(overrides?.NoYear), - noAKA: Boolean(overrides?.NoAKA), - noTag: Boolean(overrides?.NoTag), - noEdition: Boolean(overrides?.NoEdition), - noDub: Boolean(overrides?.NoDub), - noDual: Boolean(overrides?.NoDual), - dualAudio: Boolean(overrides?.DualAudio), - region: overrides?.Region ?? "", -}); - -const buildReleaseTouchedState = (overrides?: ReleaseNameOverrides): ReleaseNameTouchedState => ({ - category: overrides?.Category !== undefined && overrides?.Category !== null, - type: overrides?.Type !== undefined && overrides?.Type !== null, - source: overrides?.Source !== undefined && overrides?.Source !== null, - resolution: overrides?.Resolution !== undefined && overrides?.Resolution !== null, - tag: overrides?.Tag !== undefined && overrides?.Tag !== null, - service: overrides?.Service !== undefined && overrides?.Service !== null, - edition: overrides?.Edition !== undefined && overrides?.Edition !== null, - season: overrides?.Season !== undefined && overrides?.Season !== null, - episode: overrides?.Episode !== undefined && overrides?.Episode !== null, - episodeTitle: overrides?.EpisodeTitle !== undefined && overrides?.EpisodeTitle !== null, - manualYear: overrides?.ManualYear !== undefined && overrides?.ManualYear !== null, - manualDate: overrides?.ManualDate !== undefined && overrides?.ManualDate !== null, - useSeasonEpisode: - overrides?.UseSeasonEpisode !== undefined && overrides?.UseSeasonEpisode !== null, - noSeason: overrides?.NoSeason !== undefined && overrides?.NoSeason !== null, - noYear: overrides?.NoYear !== undefined && overrides?.NoYear !== null, - noAKA: overrides?.NoAKA !== undefined && overrides?.NoAKA !== null, - noTag: overrides?.NoTag !== undefined && overrides?.NoTag !== null, - noEdition: overrides?.NoEdition !== undefined && overrides?.NoEdition !== null, - noDub: overrides?.NoDub !== undefined && overrides?.NoDub !== null, - noDual: overrides?.NoDual !== undefined && overrides?.NoDual !== null, - dualAudio: overrides?.DualAudio !== undefined && overrides?.DualAudio !== null, - region: overrides?.Region !== undefined && overrides?.Region !== null, -}); - -const normalizeTag = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (trimmed.startsWith("-")) return trimmed; - return `-${trimmed}`; -}; - -const isValidManualDate = (value: string) => { - if (!value.trim()) return true; - return /^\d{4}-\d{2}-\d{2}$/.test(value.trim()); -}; - -type ThemeMode = "light" | "dark" | "auto"; - -const emptyWebAuthStatus: WebAuthStatus = { - path: "", - exists: false, - usable: false, - canCreate: false, - username: "", - allowUnencryptedExport: false, - browseRoot: "", - allowUnrestrictedBrowse: false, - encryptionEnabled: false, - message: "", -}; - -export default function App() { - const browserMode = isBrowserMode(); - const browserNativeBrowseAvailable = useSyncExternalStore( - subscribeBrowserNativeBrowseAvailability, - isBrowserNativeBrowseAvailable, - () => true, - ); - const [path, setPath] = useState(""); - const [sourcePathHistory, setSourcePathHistory] = useState(() => { - try { - return normalizeSourcePathHistory( - JSON.parse(localStorage.getItem(sourcePathHistoryStorageKey) || "[]"), - defaultInputHistoryLimit, - isRuntimePathCaseInsensitive(), - ); - } catch { - return []; - } - }); - const [sourcePathMode, setSourcePathMode] = useState(); - const [currentDiscType, setCurrentDiscType] = useState(""); - const [imageAssetsRevision, setImageAssetsRevision] = useState(0); - const [sourceLookupURL, setSourceLookupURL] = useState(""); - const [loading, setLoading] = useState(false); - const [metadataResetting, setMetadataResetting] = useState(false); - const [error, setError] = useState(""); - const [preview, setPreview] = useState(emptyPreview); - const [idEdits, setIdEdits] = useState(() => buildIDEditState(emptyPreview.ExternalIDs)); - const [idTouched, setIdTouched] = useState>(() => - buildIDTouchedState(), - ); - const [releaseEdits, setReleaseEdits] = useState(() => - buildReleaseEditState(emptyPreview.ReleaseNameOverrides), - ); - const [releaseTouched, setReleaseTouched] = useState(() => - buildReleaseTouchedState(emptyPreview.ReleaseNameOverrides), - ); - const [showExternalIDInputUI, setShowExternalIDInputUI] = useState(true); - const [selectedProvider, setSelectedProvider] = useState(""); - const [activeTab, setActiveTab] = useState("input"); - const [theme, setTheme] = useState("auto"); - const [renderedDescriptions, setRenderedDescriptions] = useState>({}); - const [bluraySelecting, setBluraySelecting] = useState(false); - const [bluraySelectionError, setBluraySelectionError] = useState(""); - const [lightboxImage, setLightboxImage] = useState(""); - const [lightboxAlt, setLightboxAlt] = useState(""); - const [lightboxFit, setLightboxFit] = useState(true); - const [showPlaylistSelection, setShowPlaylistSelection] = useState(false); - const [playlistSelectionPath, setPlaylistSelectionPath] = useState(""); - const [playlistAutoPreparing, setPlaylistAutoPreparing] = useState(false); - const [playlistPreparationError, setPlaylistPreparationError] = useState(""); - const [playlistPreparationTrackerSnapshot, setPlaylistPreparationTrackerSnapshot] = useState<{ - selectedTrackers: string[]; - emptySelection: boolean; - } | null>(null); - const [bdinfoProgressLines, setBdinfoProgressLines] = useState([]); - const bdinfoProgressActiveRef = useRef(false); - const [metadataProgressActive, setMetadataProgressActive] = useState(false); - const [metadataProgressUpdates, setMetadataProgressUpdates] = useState( - [], - ); - // Mirrors metadata progress state synchronously so events emitted during the - // same tick as a fetch/reset request are not dropped by a stale React closure. - const metadataProgressActiveRef = useRef(false); - const metadataProgressTargetRef = useRef(""); - const discTypeRequestTokenRef = useRef(0); - const [dupeSummary, setDupeSummary] = useState(emptyDupeSummary); - const [dupeLoading, setDupeLoading] = useState(false); - const [dupeError, setDupeError] = useState(""); - const [dupeChecked, setDupeChecked] = useState(false); - const [dupeCheckJobID, setDupeCheckJobID] = useState(""); - const [dupeCheckSnapshot, setDupeCheckSnapshot] = useState(null); - const [dupeIgnore, setDupeIgnore] = useState>({}); - const [dupeTrackerFlags, setDupeTrackerFlags] = useState>({}); - const [builderPreview, setBuilderPreview] = - useState(emptyDescriptionBuilder); - const [builderRawByGroup, setBuilderRawByGroup] = useState>({}); - const [builderRenderedByGroup, setBuilderRenderedByGroup] = useState>({}); - const [builderExpandedGroups, setBuilderExpandedGroups] = useState>({}); - const [builderLoading, setBuilderLoading] = useState(false); - const [builderError, setBuilderError] = useState(""); - const [builderDirtyByGroup, setBuilderDirtyByGroup] = useState>({}); - const [builderRenderLoading, setBuilderRenderLoading] = useState(false); - const [builderSaved, setBuilderSaved] = useState(""); - const [builderSaving, setBuilderSaving] = useState(false); - const [builderRefreshing, setBuilderRefreshing] = useState(false); - const [builderProgressMessage, setBuilderProgressMessage] = useState(""); - const builderProgressTimers = useRef([]); - const [builderAutoRequestKey, setBuilderAutoRequestKey] = useState(""); - const [uploadToggles, setUploadToggles] = useState>({}); - /** - * Tracks upload toggles disabled by dupe hits so a later Ignore override can - * restore only automatic disables, not user-disabled upload targets. - */ - const autoDisabledUploadTrackersRef = useRef>(new Set()); - const [uploadSkipClientInjection, setUploadSkipClientInjection] = useState(false); - const [trackerUploadRunning, setTrackerUploadRunning] = useState(false); - const [trackerUploadError, setTrackerUploadError] = useState(""); - const [trackerUploadJobID, setTrackerUploadJobID] = useState(""); - const [trackerUploadSnapshot, setTrackerUploadSnapshot] = useState( - null, - ); - const [trackerDryRunLoading, setTrackerDryRunLoading] = useState(false); - const [trackerDryRunError, setTrackerDryRunError] = useState(""); - const [trackerDryRunPreview, setTrackerDryRunPreview] = - useState(emptyTrackerDryRun); - const [trackerDryRunProgress, setTrackerDryRunProgress] = useState( - null, - ); - const [trackerQuestionnaireAnswers, setTrackerQuestionnaireAnswers] = useState< - Record> - >({}); - const [releasePageTrackerSelection, setReleasePageTrackerSelection] = useState< - Record - >({}); - const [runDebug, setRunDebug] = useState(false); - const [runLogLevel, setRunLogLevel] = useState("info"); - const [runLogLevelTouched, setRunLogLevelTouched] = useState(false); - const [liveCaptureLoading, setLiveCaptureLoading] = useState(false); - const [finalDragIndex, setFinalDragIndex] = useState(null); - const [settingsExporting, setSettingsExporting] = useState(false); - const [settingsImporting, setSettingsImporting] = useState(false); - const [importConfirmOpen, setImportConfirmOpen] = useState(false); - const [configOpStatus, setConfigOpStatus] = useState<{ - type: "success" | "error" | "warning"; - title: string; - message: string; - warnings?: string[]; - } | null>(null); - const [webAuthStatus, setWebAuthStatus] = useState(null); - const [webAuthLoading, setWebAuthLoading] = useState(false); - const [webAuthCreating, setWebAuthCreating] = useState(false); - const [webAuthUsername, setWebAuthUsername] = useState(""); - const [webAuthPassword, setWebAuthPassword] = useState(""); - const [webAuthConfirm, setWebAuthConfirm] = useState(""); - const [webAuthError, setWebAuthError] = useState(""); - const [applicationInfo, setApplicationInfo] = useState(null); - const configOpTimerRef = useRef | null>(null); - const sourcePathDropHandlerRef = useRef<(paths: string[]) => void>(() => undefined); - const [hostBrowserMode, setHostBrowserMode] = useState<"file" | "folder" | null>(null); - const [hostBrowser, setHostBrowser] = useState(null); - const [hostBrowserLoading, setHostBrowserLoading] = useState(false); - const [hostBrowserError, setHostBrowserError] = useState(""); - const [hostBrowserSearch, setHostBrowserSearch] = useState(""); - const [debouncedHostBrowserSearch, setDebouncedHostBrowserSearch] = useState(""); - const hostBrowserEntryRefs = useRef>([]); - - const builderDirty = useMemo( - () => Object.values(builderDirtyByGroup).some(Boolean), - [builderDirtyByGroup], - ); - const builderReady = useMemo(() => { - const normalizedPath = path.trim(); - if (!normalizedPath) { - return false; - } - return builderPreview.SourcePath === normalizedPath && builderPreview.Groups !== undefined; - }, [builderPreview.SourcePath, builderPreview.Groups, path]); - - const { - configData, - settingsLoading, - settingsDirty, - settingsSaved, - settingsError, - settingsSection, - settingsSections, - showAdvancedToggle, - advancedOpen, - setSettingsSection, - setSettingsAdvanced, - loadSettings, - handleSaveSettings, - renderImageHostingSection, - renderTrackerSection, - renderTorrentClientsSection, - renderField, - sectionFieldMeta, - updateConfigValue, - updateScreenshotConfigValue, - configuredImageHosts, - screenshotConfig, - buildSavePayload, - clearSettingsStatus, - markSettingsSaved, - resolveImageHostLabel, - trackerSelectionNames, - } = useSettingsState({ activeTab }); - - const inputHistoryLimit = useMemo(() => { - const mainSettings = ((configData as ConfigMap | null)?.MainSettings ?? - null) as ConfigMap | null; - return resolveInputHistoryLimit(mainSettings?.InputHistoryLimit); - }, [configData]); - const useFavicons = useMemo(() => { - const mainSettings = ((configData as ConfigMap | null)?.MainSettings ?? - null) as ConfigMap | null; - return typeof mainSettings?.UseFavicons === "boolean" ? mainSettings.UseFavicons : true; - }, [configData]); - const faviconOnly = useMemo(() => { - const mainSettings = ((configData as ConfigMap | null)?.MainSettings ?? - null) as ConfigMap | null; - return typeof mainSettings?.FaviconOnly === "boolean" ? mainSettings.FaviconOnly : false; - }, [configData]); - const maxMenuItems = useMemo(() => { - const value = screenshotConfig?.MaxMenuItems; - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 6; - }, [screenshotConfig]); - - const persistSourcePathHistory = useCallback((entries: SourcePathHistoryEntry[]) => { - try { - if (entries.length === 0) { - localStorage.removeItem(sourcePathHistoryStorageKey); - return; - } - localStorage.setItem(sourcePathHistoryStorageKey, JSON.stringify(entries)); - } catch { - // Storage may be unavailable in locked-down browser sessions. - } - }, []); - - const rememberSourcePath = useCallback( - (value: string, mode?: SourcePathMode) => { - setSourcePathHistory((prev) => { - const next = addSourcePathHistoryEntry( - prev, - value, - mode ?? inferSourcePathMode(value), - inputHistoryLimit, - isRuntimePathCaseInsensitive(), - ); - persistSourcePathHistory(next); - return next; - }); - }, - [inputHistoryLimit, persistSourcePathHistory], - ); - - const handleSourcePathChange = useCallback((value: string) => { - discTypeRequestTokenRef.current += 1; - setPath(value); - setSourcePathMode(undefined); - setCurrentDiscType(""); - }, []); - - useEffect(() => { - setSourcePathHistory((prev) => { - const next = normalizeSourcePathHistory( - prev, - inputHistoryLimit, - isRuntimePathCaseInsensitive(), - ); - persistSourcePathHistory(next); - return next; - }); - }, [inputHistoryLimit, persistSourcePathHistory]); - - const configuredRunLogLevel = useMemo(() => { - const loggingSection = ((configData as ConfigMap | null)?.Logging ?? null) as ConfigMap | null; - const rawLevel = String(loggingSection?.Level ?? "info") - .toLowerCase() - .trim(); - return runLogLevels.includes(rawLevel as (typeof runLogLevels)[number]) ? rawLevel : "info"; - }, [configData]); - - useEffect(() => { - if (runLogLevelTouched) { - return; - } - setRunLogLevel(runDebug ? "debug" : configuredRunLogLevel); - }, [configuredRunLogLevel, runDebug, runLogLevelTouched]); - - // Initialize theme from localStorage and detect system preference - useEffect(() => { - const savedTheme = (localStorage.getItem("theme") as ThemeMode | null) || "auto"; - setTheme(savedTheme); - applyTheme(savedTheme); - }, []); - - // Apply theme to document - const applyTheme = (themeValue: ThemeMode) => { - const root = document.documentElement; - let effectiveTheme = themeValue; - - if (themeValue === "auto") { - const prefersDark = globalThis.matchMedia("(prefers-color-scheme: dark)").matches; - effectiveTheme = prefersDark ? "dark" : "light"; - } - - // Remove existing theme classes - root.classList.remove("light", "dark"); - // Add the effective theme class - root.classList.add(effectiveTheme); - }; - - const handleThemeToggle = () => { - const themes: ThemeMode[] = ["auto", "light", "dark"]; - const currentIndex = themes.indexOf(theme); - const nextTheme = themes[(currentIndex + 1) % themes.length]; - setTheme(nextTheme); - localStorage.setItem("theme", nextTheme); - applyTheme(nextTheme); - }; - - useEffect(() => { - let cancelled = false; - const getter = (globalThis.go?.guiapp?.App as AppBridgeWithApplicationInfo | undefined) - ?.GetApplicationInfo; - if (!getter) { - return () => { - cancelled = true; - }; - } - - void getter() - .then((info) => { - if (!cancelled) { - setApplicationInfo(info); - } - }) - .catch(() => undefined); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!lightboxImage) return; - setLightboxFit(true); - }, [lightboxImage]); - - useEffect(() => { - // Keep BDInfo progress subscribed before preparation starts; the backend - // can emit first lines before React commits playlistAutoPreparing state. - const off = EventsOn(bdinfoProgressEvent, (payload: any) => { - if (!bdinfoProgressActiveRef.current) { - return; - } - const line = typeof payload === "string" ? payload : payload?.line; - if (typeof line !== "string") { - return; - } - const trimmed = line.trim(); - if (!trimmed) { - return; - } - setBdinfoProgressLines((prev) => upsertProgressLine(prev, trimmed)); - }); - - return () => { - if (typeof off === "function") { - off(); - } - }; - }, []); - - useEffect(() => { - // Keep one stable metadata progress listener for the app lifetime; refs - // carry the active request state without resubscribing mid-fetch. - const off = EventsOn(metadataProgressEvent, (payload: any) => { - if (!metadataProgressActiveRef.current) { - return; - } - const eventPath = typeof payload?.path === "string" ? payload.path : ""; - const progressTarget = metadataProgressTargetRef.current; - if (!isMetadataProgressPathMatch(eventPath, progressTarget)) { - return; - } - - const update: MetadataProgressUpdate = { - path: eventPath, - phase: typeof payload?.phase === "string" ? payload.phase : "", - message: typeof payload?.message === "string" ? payload.message : "", - status: typeof payload?.status === "string" ? payload.status : "", - level: typeof payload?.level === "string" ? payload.level : "info", - timestamp: typeof payload?.timestamp === "string" ? payload.timestamp : "", - }; - - if (update.phase === "complete" && update.status === "completed") { - metadataProgressActiveRef.current = false; - metadataProgressTargetRef.current = ""; - setMetadataProgressActive(false); - setMetadataProgressUpdates([]); - return; - } - - setMetadataProgressUpdates((prev) => { - const next = [...prev, update]; - return next.length > 100 ? next.slice(-100) : next; - }); - }); - - return () => { - if (typeof off === "function") { - off(); - } - }; - }, []); - - const getThemeIcon = () => { - if (theme === "auto") return "🔄"; - if (theme === "light") return "☀️"; - return "🌙"; - }; - - const getThemeLabel = () => { - if (theme === "auto") return "Auto"; - if (theme === "light") return "Light"; - return "Dark"; - }; - - const skipAutoTorrentEnabled = isSkipAutoTorrentEnabled(configData); - const hasTrackerData = - !skipAutoTorrentEnabled && preview.TrackerData && preview.TrackerData.length > 0; - const hasBlurayData = Boolean(preview.Bluray); - const hasPreview = Boolean(preview.SourcePath); - - useEffect(() => { - if (skipAutoTorrentEnabled && activeTab === "tracker") { - setActiveTab("input"); - } - }, [activeTab, skipAutoTorrentEnabled]); - - const dupeEffectiveResults = useMemo(() => { - const trackerResults = dupeResultsFromSnapshot(dupeCheckSnapshot); - return trackerResults.length > 0 ? trackerResults : dupeSummary.Results || []; - }, [dupeCheckSnapshot, dupeSummary.Results]); - - useEffect(() => { - setDupeIgnore((prev) => { - if (dupeEffectiveResults.length === 0) { - return prev; - } - let changed = false; - const next = { ...prev }; - dupeEffectiveResults.forEach((result) => { - const tracker = result.Tracker; - if (!tracker) return; - if (next[tracker] === undefined) { - next[tracker] = false; - changed = true; - } - }); - return changed ? next : prev; - }); - }, [dupeEffectiveResults]); - - useEffect(() => { - if (dupeEffectiveResults.length === 0) { - setDupeTrackerFlags({}); - return; - } - const next: Record = {}; - dupeEffectiveResults.forEach((result) => { - const tracker = result.Tracker; - if (!tracker) return; - const ignored = dupeIgnore[tracker] ?? false; - const skipped = Boolean(result.Skipped); - next[tracker] = Boolean(result.HasDupes) && !ignored && !skipped; - }); - setDupeTrackerFlags(next); - }, [dupeEffectiveResults, dupeIgnore]); - - const dupedTrackerSet = useMemo(() => { - const next = new Set(); - Object.entries(dupeTrackerFlags).forEach(([tracker, hasDupes]) => { - if (!hasDupes) return; - const normalized = tracker.toLowerCase().trim(); - if (normalized) next.add(normalized); - splitTrackerLabel(tracker).forEach((entry) => next.add(entry)); - }); - return next; - }, [dupeTrackerFlags]); - - // Rule failures stay terminal even in debug/dry-run. Keep this set separate - // from generic skipped duplicate-check results so the UI can show the exact - // skip reason without labeling config/handler skips as rule failures. - const ruleSkippedTrackerSet = useMemo(() => { - const next = new Set(); - dupeEffectiveResults.forEach((result) => { - if (!result.Tracker || !isRuleSkippedResult(result)) return; - ruleBlockingTrackerLabels(result).forEach((tracker) => next.add(tracker)); - }); - return next; - }, [dupeEffectiveResults]); - - // Any skipped duplicate-check result blocks upload eligibility; rule skips are - // also included here but their display copy comes from ruleSkipReasons. - const skippedDupeTrackerSet = useMemo(() => { - const next = new Set(); - dupeEffectiveResults.forEach((result) => { - if (!result.Tracker || !result.Skipped) return; - ruleBlockingTrackerLabels(result).forEach((tracker) => next.add(tracker)); - }); - return next; - }, [dupeEffectiveResults]); - - const failedDupeTrackerSet = useMemo(() => { - const next = new Set(); - dupeEffectiveResults.forEach((result) => { - if (!result.Tracker) return; - const status = String(result.Status || "") - .toLowerCase() - .trim(); - const hasError = Boolean(String(result.Error || "").trim()); - if (status !== "failed" && !hasError) return; - const normalized = result.Tracker.toLowerCase().trim(); - if (normalized) next.add(normalized); - splitTrackerLabel(result.Tracker).forEach((tracker) => next.add(tracker)); - }); - return next; - }, [dupeEffectiveResults]); - - // Generic skipped reasons cover handler/config skips such as missing API keys. - // Rule-specific labels are derived separately to avoid misleading copy. - const skippedDupeReasons = useMemo(() => { - const next: Record = {}; - dupeEffectiveResults.forEach((result) => { - if (!result.Tracker || !result.Skipped) return; - const reason = dupeSkipReason(result) || "Skipped"; - ruleBlockingTrackerLabels(result).forEach((tracker) => { - next[tracker] = reason; - }); - }); - return next; - }, [dupeEffectiveResults]); - - const ruleSkipReasons = useMemo(() => { - const next: Record = {}; - dupeEffectiveResults.forEach((result) => { - if (!result.Tracker || !isRuleSkippedResult(result)) return; - const reason = dupeSkipReason(result); - ruleBlockingTrackerLabels(result).forEach((tracker) => { - next[tracker] = reason || "rule check failed"; - }); - }); - return next; - }, [dupeEffectiveResults]); - - const ignoredDupeTrackers = useMemo(() => { - const next = new Set(); - Object.entries(dupeIgnore).forEach(([tracker, ignored]) => { - if (!ignored) return; - const normalized = tracker.toLowerCase().trim(); - if (normalized) next.add(normalized); - splitTrackerLabel(tracker).forEach((entry) => next.add(entry)); - }); - return Array.from(next); - }, [dupeIgnore]); - - const trackerUploadItems = useMemo(() => { - if (!configData || !configData.Trackers || typeof configData.Trackers !== "object") { - return []; - } - - const trackerRoot = configData.Trackers as ConfigMap; - const rawEntries = trackerRoot.Trackers; - const entriesRoot = - rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries) - ? (rawEntries as ConfigMap) - : {}; - const entries = Object.entries(entriesRoot).filter( - ([, value]) => value && typeof value === "object" && !Array.isArray(value), - ) as Array<[string, ConfigMap]>; - const visibleTrackerSet = new Set(trackerSelectionNames); - - return entries - .filter(([name]) => visibleTrackerSet.has(name)) - .map(([name, config]) => ({ name, config })) - .sort((left, right) => left.name.localeCompare(right.name)); - }, [configData, trackerSelectionNames]); - const trackerIconSrcByName = useTrackerIcons(trackerUploadItems, useFavicons); - - const defaultTrackerSet = useMemo(() => { - if (!configData || !configData.Trackers || typeof configData.Trackers !== "object") { - return new Set(); - } - const trackerRoot = configData.Trackers as ConfigMap; - const defaults = normalizeDefaultTrackerList(trackerRoot.DefaultTrackers); - return new Set(defaults.map((entry) => entry.toLowerCase())); - }, [configData]); - - const idOverrideState = useMemo(() => { - const parsed = { - tmdb: parseIDInput("tmdb", idEdits.tmdb), - imdb: parseIDInput("imdb", idEdits.imdb), - tvdb: parseIDInput("tvdb", idEdits.tvdb), - tvmaze: parseIDInput("tvmaze", idEdits.tvmaze), - mal: parseIDInput("mal", idEdits.mal), - }; - - const invalid = Object.values(parsed).includes(null); - const overrides: ExternalIDOverrides = { - TMDBID: parsed.tmdb !== null && idTouched.tmdb ? parsed.tmdb : null, - IMDBID: parsed.imdb !== null && idTouched.imdb ? parsed.imdb : null, - TVDBID: parsed.tvdb !== null && idTouched.tvdb ? parsed.tvdb : null, - TVmazeID: parsed.tvmaze !== null && idTouched.tvmaze ? parsed.tvmaze : null, - MALID: parsed.mal !== null && idTouched.mal ? parsed.mal : null, - }; - const dirty = Object.values(overrides).some((value) => value !== null); - - return { overrides, dirty, invalid }; - }, [idEdits, idTouched]); - - const releaseOverrideState = useMemo(() => { - // Safety check: ensure state is initialized - if (!releaseEdits || !releaseTouched) { - return { overrides: {}, dirty: false, invalid: false }; - } - - const overrides: ReleaseNameOverrides = {}; - const stored = - preview.ReleaseNameOverrides && typeof preview.ReleaseNameOverrides === "object" - ? preview.ReleaseNameOverrides - : {}; - let invalid = false; - - const readTrimmed = (value: string | null | undefined) => (value || "").trim(); - const stringDirty = ( - touched: boolean, - current: string | null | undefined, - storedValue?: string | null, - ) => { - if (!touched) return false; - if (storedValue === undefined || storedValue === null) return true; - return readTrimmed(current) !== readTrimmed(storedValue); - }; - const boolDirty = ( - touched: boolean, - current: boolean | null | undefined, - storedValue?: boolean | null, - ) => { - if (!touched) return false; - if (storedValue === undefined || storedValue === null) return true; - return Boolean(current) !== Boolean(storedValue); - }; - - if (releaseTouched.category) overrides.Category = readTrimmed(releaseEdits.category); - if (releaseTouched.type) overrides.Type = readTrimmed(releaseEdits.type); - if (releaseTouched.source) overrides.Source = readTrimmed(releaseEdits.source); - if (releaseTouched.resolution) overrides.Resolution = readTrimmed(releaseEdits.resolution); - if (releaseTouched.tag) overrides.Tag = normalizeTag(releaseEdits.tag); - if (releaseTouched.service) overrides.Service = readTrimmed(releaseEdits.service); - if (releaseTouched.edition) overrides.Edition = readTrimmed(releaseEdits.edition); - if (releaseTouched.season) overrides.Season = readTrimmed(releaseEdits.season); - if (releaseTouched.episode) overrides.Episode = readTrimmed(releaseEdits.episode); - if (releaseTouched.episodeTitle) - overrides.EpisodeTitle = readTrimmed(releaseEdits.episodeTitle); - - if (releaseTouched.manualYear) { - const trimmed = readTrimmed(releaseEdits.manualYear); - if (!trimmed) { - overrides.ManualYear = 0; - } else if (!/^\d+$/.test(trimmed)) { - invalid = true; - } else { - overrides.ManualYear = Number(trimmed); - } - } - - if (releaseTouched.manualDate) { - const trimmed = readTrimmed(releaseEdits.manualDate); - overrides.ManualDate = trimmed; - if (!isValidManualDate(trimmed)) { - invalid = true; - } - } - - if (releaseTouched.useSeasonEpisode) { - overrides.UseSeasonEpisode = Boolean(releaseEdits.useSeasonEpisode); - } - - if (releaseTouched.noSeason) overrides.NoSeason = releaseEdits.noSeason; - if (releaseTouched.noYear) overrides.NoYear = releaseEdits.noYear; - if (releaseTouched.noAKA) overrides.NoAKA = releaseEdits.noAKA; - if (releaseTouched.noTag) overrides.NoTag = releaseEdits.noTag; - if (releaseTouched.noEdition) overrides.NoEdition = releaseEdits.noEdition; - if (releaseTouched.noDub) overrides.NoDub = releaseEdits.noDub; - if (releaseTouched.noDual) overrides.NoDual = releaseEdits.noDual; - if (releaseTouched.dualAudio) overrides.DualAudio = releaseEdits.dualAudio; - if (releaseTouched.region) overrides.Region = readTrimmed(releaseEdits.region); - - const dirty = - stringDirty(releaseTouched.category, releaseEdits.category, stored.Category) || - stringDirty(releaseTouched.type, releaseEdits.type, stored.Type) || - stringDirty(releaseTouched.source, releaseEdits.source, stored.Source) || - stringDirty(releaseTouched.resolution, releaseEdits.resolution, stored.Resolution) || - stringDirty(releaseTouched.tag, normalizeTag(releaseEdits.tag), stored.Tag) || - stringDirty(releaseTouched.service, releaseEdits.service, stored.Service) || - stringDirty(releaseTouched.edition, releaseEdits.edition, stored.Edition) || - stringDirty(releaseTouched.season, releaseEdits.season, stored.Season) || - stringDirty(releaseTouched.episode, releaseEdits.episode, stored.Episode) || - stringDirty(releaseTouched.episodeTitle, releaseEdits.episodeTitle, stored.EpisodeTitle) || - stringDirty(releaseTouched.manualDate, releaseEdits.manualDate, stored.ManualDate) || - boolDirty( - releaseTouched.useSeasonEpisode, - releaseEdits.useSeasonEpisode, - stored.UseSeasonEpisode, - ) || - boolDirty(releaseTouched.noSeason, releaseEdits.noSeason, stored.NoSeason) || - boolDirty(releaseTouched.noYear, releaseEdits.noYear, stored.NoYear) || - boolDirty(releaseTouched.noAKA, releaseEdits.noAKA, stored.NoAKA) || - boolDirty(releaseTouched.noTag, releaseEdits.noTag, stored.NoTag) || - boolDirty(releaseTouched.noEdition, releaseEdits.noEdition, stored.NoEdition) || - boolDirty(releaseTouched.noDub, releaseEdits.noDub, stored.NoDub) || - boolDirty(releaseTouched.noDual, releaseEdits.noDual, stored.NoDual) || - boolDirty(releaseTouched.dualAudio, releaseEdits.dualAudio, stored.DualAudio) || - stringDirty(releaseTouched.region, releaseEdits.region, stored.Region) || - (() => { - if (!releaseTouched.manualYear) return false; - if (stored.ManualYear === undefined || stored.ManualYear === null) return true; - return readTrimmed(releaseEdits.manualYear) !== String(stored.ManualYear); - })(); - - return { overrides, dirty, invalid }; - }, [releaseEdits, releaseTouched, preview.ReleaseNameOverrides]); - - // Screenshot workflow hook (now idOverrideState/releaseOverrideState are defined) - const screenshots = useScreenshots({ - path, - idOverrideState, - releaseOverrideState, - }); - - // Destructure commonly used screenshot variables - const { - livePreviewSeconds, - setLivePreviewSeconds, - livePreviewError, - setLivePreviewError, - livePreviewLoading, - setLivePreviewLoading, - livePreviewImage, - setLivePreviewImage, - livePreviewRequestId, - screenshotsSettingsSaving, - setScreenshotsSettingsSaving, - loadScreenshotPlan, - readScreenshotImage, - setExistingImages, - resetScreenshotState: resetScreenshots, - handleDeleteTrackerImageURL, - } = screenshots; - - /** - * Resolves upload-eligible trackers for the requested source path. - * - * Dupe and rule-failure filters apply only when their snapshot source path - * matches the requested path context, preventing stale blocks from a previous - * source path from changing metadata fetch or playlist preparation payloads. - * Callers starting a fresh dupe check can disable dupe filters while keeping - * manual upload tracker toggles. - */ - const resolveUploadTrackerEligibilityForPath = useCallback( - (sourcePath: string, options: { applyDupeFilters?: boolean } = {}) => { - const targetPath = sourcePath.trim(); - const currentPath = path.trim(); - const dupeSourcePath = String( - dupeSummary.SourcePath || dupeCheckSnapshot?.sourcePath || "", - ).trim(); - const pathCaseInsensitive = isRuntimePathCaseInsensitive(); - const applyDupeFilters = options.applyDupeFilters ?? true; - const dupeSourceMatchesTarget = - applyDupeFilters && - targetPath !== "" && - dupeSourcePath !== "" && - isSourcePathContextMatch(dupeSourcePath, targetPath, pathCaseInsensitive); - const uploadTogglesMatchTarget = - targetPath === "" || - currentPath === "" || - isSourcePathContextMatch(currentPath, targetPath, pathCaseInsensitive); - const effectiveUploadToggles: Record = {}; - - trackerUploadItems.forEach((item) => { - const normalized = item.name.toLowerCase().trim(); - const ignoreAutoDisabledToggle = - !applyDupeFilters && autoDisabledUploadTrackersRef.current.has(normalized); - if ( - uploadTogglesMatchTarget && - hasOwnSelection(uploadToggles, item.name) && - !ignoreAutoDisabledToggle - ) { - effectiveUploadToggles[item.name] = uploadToggles[item.name]; - return; - } - if (hasOwnSelection(releasePageTrackerSelection, item.name)) { - effectiveUploadToggles[item.name] = releasePageTrackerSelection[item.name]; - return; - } - effectiveUploadToggles[item.name] = defaultTrackerSet.has(normalized); - }); - - const emptyTrackerSet = new Set(); - const scopedDupedTrackerSet = dupeSourceMatchesTarget ? dupedTrackerSet : emptyTrackerSet; - const scopedSkippedDupeTrackerSet = dupeSourceMatchesTarget - ? skippedDupeTrackerSet - : emptyTrackerSet; - const scopedRuleSkippedTrackerSet = dupeSourceMatchesTarget - ? ruleSkippedTrackerSet - : emptyTrackerSet; - const scopedFailedDupeTrackerSet = dupeSourceMatchesTarget - ? failedDupeTrackerSet - : emptyTrackerSet; - const selectedTrackers = resolveSelectedUploadTrackers({ - trackerUploadItems, - releasePageTrackerSelection, - uploadToggles: effectiveUploadToggles, - dupedTrackerSet: scopedDupedTrackerSet, - skippedDupeTrackerSet: scopedSkippedDupeTrackerSet, - ruleSkippedTrackerSet: scopedRuleSkippedTrackerSet, - failedDupeTrackerSet: scopedFailedDupeTrackerSet, - }); - - return { - selectedTrackers, - emptySelection: - hasExplicitEmptyReleaseTrackerSelection( - trackerUploadItems, - releasePageTrackerSelection, - ) || - hasFilteredEmptyUploadTrackerSelectionState({ - trackerUploadItems, - releasePageTrackerSelection, - uploadToggles: effectiveUploadToggles, - dupedTrackerSet: scopedDupedTrackerSet, - skippedDupeTrackerSet: scopedSkippedDupeTrackerSet, - ruleSkippedTrackerSet: scopedRuleSkippedTrackerSet, - failedDupeTrackerSet: scopedFailedDupeTrackerSet, - }), - }; - }, - [ - defaultTrackerSet, - dupeCheckSnapshot?.sourcePath, - dupeSummary.SourcePath, - dupedTrackerSet, - failedDupeTrackerSet, - path, - releasePageTrackerSelection, - ruleSkippedTrackerSet, - skippedDupeTrackerSet, - trackerUploadItems, - uploadToggles, - ], - ); - - const selectedUploadTrackerEligibility = useMemo( - () => resolveUploadTrackerEligibilityForPath(path), - [path, resolveUploadTrackerEligibilityForPath], - ); - const selectedUploadImageTrackers = selectedUploadTrackerEligibility.selectedTrackers; - const dupeFiltersMatchCurrentPath = useMemo(() => { - const currentPath = path.trim(); - const dupeSourcePath = String( - dupeSummary.SourcePath || dupeCheckSnapshot?.sourcePath || "", - ).trim(); - return ( - currentPath !== "" && - dupeSourcePath !== "" && - isSourcePathContextMatch(dupeSourcePath, currentPath, isRuntimePathCaseInsensitive()) - ); - }, [dupeCheckSnapshot?.sourcePath, dupeSummary.SourcePath, path]); - - // Upload images workflow hook - const uploadImages = useUploadImages({ - path, - idOverrideState, - releaseOverrideState, - uploadCandidates: screenshots.uploadCandidates, - configuredImageHosts, - selectedTrackers: selectedUploadImageTrackers, - }); - const { - refreshUploadedImages, - resetUploadState, - setUploadSelections, - setUploadHost, - uploadHost, - handleUploadImages: uploadSelectedImages, - handleDeleteUploadedImage: deleteUploadedImage, - } = uploadImages; - - // Tracker image URL handling - const trackerImageURLs = useMemo(() => { - const urls = new Set(); - (preview.TrackerData || []).forEach((tracker) => { - (tracker.ImageURLs || []).forEach((url) => { - if (url) { - urls.add(url); - } - }); - }); - if (screenshots.deletedTrackerImages.length === 0) { - return Array.from(urls); - } - const deleted = new Set(screenshots.deletedTrackerImages); - return Array.from(urls).filter((url) => !deleted.has(url)); - }, [preview.TrackerData, screenshots.deletedTrackerImages]); - - const handleDeleteAllTrackerImageURLs = useCallback(async () => { - if (trackerImageURLs.length === 0) { - return; - } - if (!globalThis.confirm("Remove all tracker images from the list?")) { - return; - } - for (const url of trackerImageURLs) { - await handleDeleteTrackerImageURL(url); - } - }, [trackerImageURLs, handleDeleteTrackerImageURL]); - - const uploadCandidatePaths = useMemo(() => { - return new Set( - screenshots.uploadCandidates - .map((item) => item.image.Path) - .filter((path): path is string => Boolean(path)), - ); - }, [screenshots.uploadCandidates]); - - const markImageAssetsChanged = useCallback(() => { - setImageAssetsRevision((revision) => revision + 1); - }, []); - - const handleUploadImagesWithRevision = useCallback( - async (selected: ScreenshotPreviewImage[]) => { - await uploadSelectedImages(selected); - markImageAssetsChanged(); - }, - [markImageAssetsChanged, uploadSelectedImages], - ); - - const handleDeleteUploadedImageWithRevision = useCallback( - async (imagePath: string, host: string) => { - await deleteUploadedImage(imagePath, host); - markImageAssetsChanged(); - }, - [deleteUploadedImage, markImageAssetsChanged], - ); - - const resetScreenshotState = useCallback(() => { - resetScreenshots(); - resetUploadState(); - setUploadToggles({}); - setFinalDragIndex(null); - setLiveCaptureLoading(false); - }, [resetScreenshots, resetUploadState]); - - /** - * Clears release-specific workflow state after the loaded release is no longer current. - * - * Input tracker selections are intentionally preserved so the next metadata fetch keeps the - * user's configured/default tracker choices. - */ - const resetFreshWorkflowState = useCallback( - (nextActiveTab = "input") => { - discTypeRequestTokenRef.current += 1; - setPath(""); - setSourcePathMode(undefined); - setCurrentDiscType(""); - setImageAssetsRevision(0); - setSourceLookupURL(""); - setLoading(false); - setMetadataResetting(false); - setError(""); - setPreview(emptyPreview); - setIdEdits(buildIDEditState(emptyPreview.ExternalIDs)); - setIdTouched(buildIDTouchedState()); - setReleaseEdits(buildReleaseEditState(emptyPreview.ReleaseNameOverrides)); - setReleaseTouched(buildReleaseTouchedState(emptyPreview.ReleaseNameOverrides)); - setShowExternalIDInputUI(true); - setSelectedProvider(""); - setActiveTab(nextActiveTab); - setRenderedDescriptions({}); - setLightboxImage(""); - setLightboxAlt(""); - setShowPlaylistSelection(false); - setPlaylistSelectionPath(""); - setPlaylistAutoPreparing(false); - bdinfoProgressActiveRef.current = false; - setPlaylistPreparationError(""); - setBdinfoProgressLines([]); - metadataProgressTargetRef.current = ""; - metadataProgressActiveRef.current = false; - setMetadataProgressActive(false); - setMetadataProgressUpdates([]); - setDupeSummary(emptyDupeSummary); - setDupeLoading(false); - setDupeError(""); - setDupeChecked(false); - setDupeCheckJobID(""); - setDupeCheckSnapshot(null); - setDupeIgnore({}); - setDupeTrackerFlags({}); - autoDisabledUploadTrackersRef.current.clear(); - setBuilderPreview(emptyDescriptionBuilder); - setBuilderRawByGroup({}); - setBuilderRenderedByGroup({}); - setBuilderExpandedGroups({}); - setBuilderLoading(false); - setBuilderError(""); - setBuilderDirtyByGroup({}); - setBuilderRenderLoading(false); - setBuilderSaved(""); - setBuilderSaving(false); - setBuilderRefreshing(false); - setBuilderAutoRequestKey(""); - resetScreenshotState(); - setTrackerUploadRunning(false); - setTrackerUploadError(""); - setTrackerUploadJobID(""); - setTrackerUploadSnapshot(null); - setTrackerDryRunLoading(false); - setTrackerDryRunError(""); - setTrackerDryRunPreview(emptyTrackerDryRun); - setTrackerDryRunProgress(null); - setTrackerQuestionnaireAnswers({}); - setRunDebug(false); - setRunLogLevel(configuredRunLogLevel); - setRunLogLevelTouched(false); - setLiveCaptureLoading(false); - setHostBrowserMode(null); - setHostBrowser(null); - setHostBrowserLoading(false); - setHostBrowserError(""); - }, - [configuredRunLogLevel, resetScreenshotState], - ); - - const handleHistoryReleaseDeleted = useCallback( - (deletedPath: string) => { - // The input path can be edited after loading history; reset based on the displayed release. - const loadedPath = (preview.SourcePath || path).trim(); - if (!sameSourcePath(loadedPath, deletedPath, isRuntimePathCaseInsensitive())) { - return; - } - resetFreshWorkflowState("history"); - }, - [path, preview.SourcePath, resetFreshWorkflowState], - ); - - // Helper functions for screenshot management (not in the hook) - const handleDeleteExistingImage = (image: ScreenshotImage) => { - if (!image.Path) return; - const deletedPath = image.Path; - screenshots.setExistingImages((prev) => - prev.filter((entry) => entry.image.Path !== deletedPath), - ); - if (screenshots.finalImagesRef.current.length > 0) { - screenshots.saveFinalSelections( - screenshots.finalImagesRef.current.filter((entry) => entry.image.Path !== deletedPath), - ); - } - }; - - const mergeFinalSelections = ( - current: ScreenshotPreviewImage[], - additions: ScreenshotPreviewImage[], - ) => { - if (additions.length === 0) return current; - const seen = new Map(); - const merged = [...current]; - merged.forEach((item, index) => { - if (item.image.Path) { - seen.set(item.image.Path, index); - } - }); - additions.forEach((item) => { - const pathValue = item.image.Path; - if (!pathValue) return; - const existingIndex = seen.get(pathValue); - if (existingIndex === undefined) { - const ts = item.image.TimestampSeconds || 0; - if (ts > 0) { - const insertAt = merged.findIndex((entry) => { - const entryTs = entry.image.TimestampSeconds || 0; - return entryTs > 0 && entryTs > ts; - }); - if (insertAt >= 0) { - merged.splice(insertAt, 0, item); - seen.clear(); - merged.forEach((entry, idx) => { - if (entry.image.Path) { - seen.set(entry.image.Path, idx); - } - }); - return; - } - } - seen.set(pathValue, merged.length); - merged.push(item); - return; - } - merged[existingIndex] = item; - }); - return merged; - }; - - const reindexSelectionsByTimestamp = (selections: ScreenshotSelection[], targetIndex: number) => { - const resolveTimestamp = (entry: ScreenshotSelection) => { - if (Number.isFinite(entry.TimestampSeconds) && entry.TimestampSeconds > 0) { - return entry.TimestampSeconds; - } - if (Number.isFinite(entry.Frame) && entry.Frame > 0) { - return entry.Frame / previewFrameRate; - } - return 0; - }; - - const ordered = selections - .map((entry) => ({ entry, originalIndex: entry.Index, ts: resolveTimestamp(entry) })) - .sort((left, right) => { - if (left.ts !== right.ts) return left.ts - right.ts; - return left.originalIndex - right.originalIndex; - }); - - let resolvedIndex = -1; - const nextSelections = ordered.map((item, index) => { - if (item.originalIndex === targetIndex) { - resolvedIndex = index; - } - return item.entry; - }); - - return { selections: nextSelections, targetIndex: resolvedIndex }; - }; - - const normalizeSelectionTimestamp = (entry: ScreenshotSelection) => { - if (Number.isFinite(entry.TimestampSeconds) && entry.TimestampSeconds > 0) { - return entry.TimestampSeconds; - } - if (Number.isFinite(entry.Frame) && entry.Frame > 0) { - return entry.Frame / previewFrameRate; - } - return 0; - }; - - const desiredScreenCount = () => { - if ( - screenshotConfig && - typeof screenshotConfig.Screens === "number" && - screenshotConfig.Screens > 0 - ) { - return screenshotConfig.Screens; - } - if ( - screenshots.screenshotPlan && - Array.isArray(screenshots.screenshotPlan.SuggestedSelections) - ) { - return screenshots.screenshotPlan.SuggestedSelections.length; - } - return 0; - }; - - const regenerateAutoSelections = (current: ScreenshotSelection[]) => { - const targetCount = desiredScreenCount(); - if (targetCount <= 0) { - return current; - } - - const manual = current.filter((entry) => (entry.Source || "auto").toLowerCase() !== "auto"); - if (manual.length >= targetCount) { - return current.filter((entry) => (entry.Source || "auto").toLowerCase() !== "auto"); - } - - const candidates = (screenshots.screenshotPlan?.SuggestedSelections || []).filter((entry) => { - const source = (entry.Source || "auto").toLowerCase(); - return source === "auto"; - }); - - const tolerance = previewFrameRate > 0 ? 1 / previewFrameRate : 0; - const filtered = candidates.filter((entry) => { - const ts = normalizeSelectionTimestamp(entry); - return !manual.some( - (manualEntry) => Math.abs(normalizeSelectionTimestamp(manualEntry) - ts) <= tolerance, - ); - }); - - const needed = Math.max(0, targetCount - manual.length); - const auto = filtered.slice(0, needed).map((entry) => ({ - ...entry, - Source: "auto", - })); - - return [...manual, ...auto]; - }; - - const namingOverrides = useMemo(() => { - const stored = - preview.ReleaseNameOverrides && typeof preview.ReleaseNameOverrides === "object" - ? preview.ReleaseNameOverrides - : {}; - const overrides = releaseOverrideState?.dirty - ? releaseOverrideState.overrides - : preview.ReleaseNameOverrides || {}; - return Object.entries(overrides || {}).filter(([key, value]) => { - if (value === null || value === undefined) return false; - const storedValue = (stored as Record)[key]; - if (typeof value === "string") { - const current = value.trim(); - const prev = typeof storedValue === "string" ? storedValue.trim() : ""; - return current !== prev; - } - if (typeof value === "number") { - const prev = typeof storedValue === "number" ? storedValue : 0; - return value !== prev; - } - if (typeof value === "boolean") { - const prev = typeof storedValue === "boolean" ? storedValue : false; - return value !== prev; - } - return false; - }); - }, [preview.ReleaseNameOverrides, releaseOverrideState]); - - const refreshDisabled = - loading || - !path.trim() || - (!idOverrideState?.dirty && !releaseOverrideState?.dirty && !sourceLookupURL.trim()) || - idOverrideState?.invalid || - releaseOverrideState?.invalid; - - const normalizeOverrides = (overrides: ExternalIDOverrides) => { - const payload: ExternalIDOverrides = {}; - if (overrides.TMDBID !== null && overrides.TMDBID !== undefined) { - payload.TMDBID = overrides.TMDBID; - } - if (overrides.IMDBID !== null && overrides.IMDBID !== undefined) { - payload.IMDBID = overrides.IMDBID; - } - if (overrides.TVDBID !== null && overrides.TVDBID !== undefined) { - payload.TVDBID = overrides.TVDBID; - } - if (overrides.TVmazeID !== null && overrides.TVmazeID !== undefined) { - payload.TVmazeID = overrides.TVmazeID; - } - if (overrides.MALID !== null && overrides.MALID !== undefined) { - payload.MALID = overrides.MALID; - } - return payload; - }; - - const normalizeReleaseOverrides = (overrides: ReleaseNameOverrides) => { - const payload: ReleaseNameOverrides = {}; - if (overrides.Category !== null && overrides.Category !== undefined) { - payload.Category = overrides.Category; - } - if (overrides.Type !== null && overrides.Type !== undefined) { - payload.Type = overrides.Type; - } - if (overrides.Source !== null && overrides.Source !== undefined) { - payload.Source = overrides.Source; - } - if (overrides.Resolution !== null && overrides.Resolution !== undefined) { - payload.Resolution = overrides.Resolution; - } - if (overrides.Tag !== null && overrides.Tag !== undefined) { - payload.Tag = overrides.Tag; - } - if (overrides.Service !== null && overrides.Service !== undefined) { - payload.Service = overrides.Service; - } - if (overrides.Edition !== null && overrides.Edition !== undefined) { - payload.Edition = overrides.Edition; - } - if (overrides.Season !== null && overrides.Season !== undefined) { - payload.Season = overrides.Season; - } - if (overrides.Episode !== null && overrides.Episode !== undefined) { - payload.Episode = overrides.Episode; - } - if (overrides.EpisodeTitle !== null && overrides.EpisodeTitle !== undefined) { - payload.EpisodeTitle = overrides.EpisodeTitle; - } - if (overrides.ManualYear !== null && overrides.ManualYear !== undefined) { - payload.ManualYear = overrides.ManualYear; - } - if (overrides.ManualDate !== null && overrides.ManualDate !== undefined) { - payload.ManualDate = overrides.ManualDate; - } - if (overrides.UseSeasonEpisode !== null && overrides.UseSeasonEpisode !== undefined) { - payload.UseSeasonEpisode = overrides.UseSeasonEpisode; - } - if (overrides.NoSeason !== null && overrides.NoSeason !== undefined) { - payload.NoSeason = overrides.NoSeason; - } - if (overrides.NoYear !== null && overrides.NoYear !== undefined) { - payload.NoYear = overrides.NoYear; - } - if (overrides.NoAKA !== null && overrides.NoAKA !== undefined) { - payload.NoAKA = overrides.NoAKA; - } - if (overrides.NoTag !== null && overrides.NoTag !== undefined) { - payload.NoTag = overrides.NoTag; - } - if (overrides.NoEdition !== null && overrides.NoEdition !== undefined) { - payload.NoEdition = overrides.NoEdition; - } - if (overrides.NoDub !== null && overrides.NoDub !== undefined) { - payload.NoDub = overrides.NoDub; - } - if (overrides.NoDual !== null && overrides.NoDual !== undefined) { - payload.NoDual = overrides.NoDual; - } - if (overrides.DualAudio !== null && overrides.DualAudio !== undefined) { - payload.DualAudio = overrides.DualAudio; - } - if (overrides.Region !== null && overrides.Region !== undefined) { - payload.Region = overrides.Region; - } - return payload; - }; - - const applyPreviewResult = ( - result: MetadataPreview, - options: { switchToInput?: boolean; preserveIDTouches?: boolean } = {}, - ) => { - const { switchToInput = true, preserveIDTouches = false } = options; - setPreview(result); - if (switchToInput) { - setActiveTab("input"); - } - setIdEdits(buildIDEditState(result.ExternalIDs)); - if (!preserveIDTouches) { - setIdTouched(buildIDTouchedState()); - } - setReleaseEdits(buildReleaseEditState(result.ReleaseNameOverrides || {})); - setReleaseTouched(buildReleaseTouchedState(result.ReleaseNameOverrides || {})); - const fetchedProviders = new Set( - (result.ExternalPreview || []) - .filter(hasFetchedExternalPreviewData) - .map((item) => item.Provider), - ); - const orderedIDs = filterAndOrderExternalIDs(result.ExternalIDInfo || []).filter((item) => - fetchedProviders.has(item.Provider), - ); - if (orderedIDs.length > 0) { - setSelectedProvider(orderedIDs[0].Provider); - } else { - setSelectedProvider(""); - } - setDupeSummary(emptyDupeSummary); - setDupeError(""); - setBuilderPreview(emptyDescriptionBuilder); - setBuilderRawByGroup({}); - setBuilderRenderedByGroup({}); - setBuilderExpandedGroups({}); - setBuilderError(""); - setBuilderDirtyByGroup({}); - setBuilderSaved(""); - setBuilderRefreshing(false); - setBuilderAutoRequestKey(""); - resetScreenshotState(); - }; - - const clearHostBrowserSearch = () => { - setHostBrowserSearch(""); - setDebouncedHostBrowserSearch(""); - }; - - const openHostBrowser = async (mode: "file" | "folder", startPath = "") => { - const browser = globalThis.go?.guiapp?.App?.BrowseDirectory; - if (!browser) { - setError("Browse is unavailable in this build."); - return; - } - setHostBrowserMode(mode); - setHostBrowserLoading(true); - setHostBrowserError(""); - clearHostBrowserSearch(); - try { - const selectedStart = startPath || path.trim(); - const result = await browser(selectedStart, mode); - setHostBrowser(result); - } catch (err) { - setHostBrowserError(String(err)); - } finally { - setHostBrowserLoading(false); - } - }; - - const runBrowse = async (mode: "file" | "folder") => { - setError(""); - const app = globalThis.go?.guiapp?.App; - if (browserMode && app?.BrowseDirectory) { - await openHostBrowser(mode); - return; - } - const browse = - mode === "file" ? app?.BrowseFile || app?.BrowsePath : app?.BrowseFolder || app?.BrowsePath; - if (!browse) { - setError("Browse is unavailable in this build."); - return; - } - try { - const selected = await browse(); - if (selected) { - await handlePathSelected(selected, mode); - } - } catch (err) { - setError(String(err)); - } - }; - - const handleBrowseFile = async () => { - await runBrowse("file"); - }; - - const handleBrowseFolder = async () => { - await runBrowse("folder"); - }; - - const closeHostBrowser = () => { - setHostBrowserMode(null); - setHostBrowser(null); - setHostBrowserError(""); - clearHostBrowserSearch(); - }; - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedHostBrowserSearch(hostBrowserSearch); - }, 250); - return () => clearTimeout(timer); - }, [hostBrowserSearch]); - - const hostBrowserEntries = useMemo( - () => filterBrowseEntries(hostBrowser?.entries || [], debouncedHostBrowserSearch), - [hostBrowser?.entries, debouncedHostBrowserSearch], - ); - - useEffect(() => { - hostBrowserEntryRefs.current = []; - }, [hostBrowserEntries]); - - useEffect(() => { - if (!hostBrowserMode || hostBrowserLoading) { - return; - } - - hostBrowserEntryRefs.current.find((entry) => entry !== null)?.focus(); - }, [hostBrowser?.currentPath, hostBrowserLoading, hostBrowserMode]); - - const browseHostDirectory = async (nextPath: string) => { - if (!hostBrowserMode) { - return; - } - await openHostBrowser(hostBrowserMode, nextPath); - }; - - const selectHostPath = async (selectedPath: string, isDir: boolean) => { - if (!hostBrowserMode) { - return; - } - if (hostBrowserMode === "folder") { - await handlePathSelected(selectedPath, "folder"); - closeHostBrowser(); - return; - } - if (isDir) { - await browseHostDirectory(selectedPath); - return; - } - await handlePathSelected(selectedPath, "file"); - closeHostBrowser(); - }; - - const moveHostBrowserEntryFocus = (currentIndex: number, direction: 1 | -1) => { - const entries = hostBrowserEntryRefs.current.filter((entry): entry is HTMLDivElement => - Boolean(entry), - ); - if (entries.length === 0) { - return; - } - const current = hostBrowserEntryRefs.current[currentIndex]; - const resolvedIndex = current ? entries.indexOf(current) : -1; - const nextIndex = - resolvedIndex >= 0 - ? (resolvedIndex + direction + entries.length) % entries.length - : direction > 0 - ? 0 - : entries.length - 1; - entries[nextIndex]?.focus(); - }; - - const handleHostBrowserEntryKeyDown = ( - event: ReactKeyboardEvent, - entry: BrowseDirectoryResponse["entries"][number], - index: number, - ) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - event.stopPropagation(); - moveHostBrowserEntryFocus(index, 1); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - event.stopPropagation(); - moveHostBrowserEntryFocus(index, -1); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - void selectHostPath(entry.path, entry.isDir); - } - }; - - const detectDiscType = async (selectedPath: string): Promise => { - const detector = globalThis.go?.guiapp?.App?.DetectDiscType; - if (detector) { - try { - const discType = await detector(selectedPath); - return discType.trim().toUpperCase(); - } catch { - // Fall through to path heuristics when detection is unavailable. - } - } - - const upperPath = selectedPath.replace(/\\/g, "/").toUpperCase(); - if (/(^|\/)BDMV(\/|$)/.test(upperPath)) { - return "BDMV"; - } - if (/(^|\/)VIDEO_TS(\/|$)/.test(upperPath)) { - return "DVD"; - } - return ""; - }; - - const updateCurrentDiscType = async (selectedPath: string): Promise => { - const requestToken = ++discTypeRequestTokenRef.current; - const discType = await detectDiscType(selectedPath); - if (requestToken !== discTypeRequestTokenRef.current) { - return null; - } - setCurrentDiscType(discType); - return discType; - }; - - // Auto-detect BDMV and show playlist selection - const handlePathSelected = async ( - selectedPath: string, - mode?: SourcePathMode, - ): Promise => { - const trimmedPath = selectedPath.trim(); - if (!trimmedPath) { - return null; - } - const selectedMode = mode ?? inferSourcePathMode(trimmedPath); - setPath(trimmedPath); - setSourcePathMode(selectedMode); - rememberSourcePath(trimmedPath, selectedMode); - const discType = await updateCurrentDiscType(trimmedPath); - if (discType === null) { - return null; - } - setShowExternalIDInputUI(true); - setPlaylistPreparationError(""); - setBdinfoProgressLines([]); - setPlaylistAutoPreparing(false); - bdinfoProgressActiveRef.current = false; - - if (selectedMode === "file") { - setShowPlaylistSelection(false); - setPlaylistSelectionPath(""); - setPlaylistPreparationTrackerSnapshot(null); - setActiveTab("input"); - return { path: trimmedPath, mode: selectedMode, waitsForPlaylistSelection: false }; - } - - if (discType !== "BDMV") { - setShowPlaylistSelection(false); - setPlaylistSelectionPath(""); - setPlaylistPreparationTrackerSnapshot(null); - setActiveTab("input"); - return { path: trimmedPath, mode: selectedMode, waitsForPlaylistSelection: false }; - } - - const upperPath = trimmedPath.toUpperCase(); - let bdmvPath = trimmedPath; - - if (!upperPath.includes("\\BDMV") && !upperPath.includes("/BDMV")) { - bdmvPath = `${trimmedPath}/BDMV`; - } - - const playlistTrackerEligibility = resolveUploadTrackerEligibilityForPath(trimmedPath); - - // Set the path for playlist discovery (component will discover the playlists) - setPlaylistSelectionPath(bdmvPath); - setPlaylistPreparationTrackerSnapshot({ - selectedTrackers: playlistTrackerEligibility.selectedTrackers, - emptySelection: playlistTrackerEligibility.emptySelection, - }); - setShowPlaylistSelection(true); - return { path: trimmedPath, mode: selectedMode, waitsForPlaylistSelection: true }; - }; - - const handleSourcePathHistorySelect = async (entry: SourcePathHistoryEntry) => { - setError(""); - await handlePathSelected(entry.path, entry.mode); - }; - - const runPlaylistBDInfo = async () => { - setPlaylistPreparationError(""); - const fetcher = globalThis.go?.guiapp?.App?.FetchPreparation; - if (!fetcher) { - setPlaylistPreparationError("Preparation preview is unavailable in this build."); - return false; - } - if (!path.trim()) { - setPlaylistPreparationError("Please select a file or folder."); - return false; - } - const selectedTrackers = - playlistPreparationTrackerSnapshot?.selectedTrackers ?? - selectedUploadTrackerEligibility.selectedTrackers; - const emptySelection = - playlistPreparationTrackerSnapshot?.emptySelection ?? - selectedUploadTrackerEligibility.emptySelection; - if (selectedTrackers.length === 0 && emptySelection) { - setPlaylistPreparationError("Select at least one tracker before preparing playlists."); - return false; - } - try { - await fetcher(path.trim(), {}, {}, selectedTrackers, []); - return true; - } catch (err) { - setPlaylistPreparationError(String(err)); - return false; - } - }; - - const handlePlaylistSelectionComplete = async () => { - setPlaylistPreparationError(""); - setBdinfoProgressLines([]); - bdinfoProgressActiveRef.current = true; - setPlaylistAutoPreparing(true); - const completed = await runPlaylistBDInfo(); - bdinfoProgressActiveRef.current = false; - setPlaylistAutoPreparing(false); - if (completed) { - setShowPlaylistSelection(false); - setPlaylistSelectionPath(""); - setPlaylistPreparationTrackerSnapshot(null); - setActiveTab("input"); - } - }; - - const runFetch = async ( - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - hideExternalIDInputUIOnSuccess = false, - options: { targetPath?: string; targetMode?: SourcePathMode; switchToInput?: boolean } = {}, - ) => { - setError(""); - setDupeChecked(false); - setDupeSummary(emptyDupeSummary); - setBuilderPreview(emptyDescriptionBuilder); - setBuilderRawByGroup({}); - setBuilderRenderedByGroup({}); - setBuilderExpandedGroups({}); - setBuilderDirtyByGroup({}); - setBluraySelectionError(""); - const fetcher = globalThis.go?.guiapp?.App?.FetchMetadata; - if (!fetcher) { - setError("Fetch metadata is unavailable in this build."); - return; - } - const targetPath = (options.targetPath ?? path).trim(); - if (!targetPath) { - setError("Please select a file or folder."); - return; - } - const trackerEligibility = resolveUploadTrackerEligibilityForPath(targetPath); - const selectedTrackers = trackerEligibility.selectedTrackers; - if (selectedTrackers.length === 0 && trackerEligibility.emptySelection) { - setError("Select at least one tracker before fetching metadata."); - return; - } - metadataProgressTargetRef.current = targetPath; - metadataProgressActiveRef.current = true; - setMetadataProgressUpdates([]); - setMetadataProgressActive(true); - setLoading(true); - try { - await updateCurrentDiscType(targetPath); - const result = await fetcher( - targetPath, - sourceLookupURL.trim(), - normalizeOverrides(overrides), - normalizeReleaseOverrides(nameOverrides), - selectedTrackers, - ); - applyPreviewResult(result, { - switchToInput: options.switchToInput, - preserveIDTouches: Object.keys(normalizeOverrides(overrides)).length > 0, - }); - rememberSourcePath( - targetPath, - options.targetMode ?? sourcePathMode ?? inferSourcePathMode(targetPath), - ); - setShowExternalIDInputUI(!hideExternalIDInputUIOnSuccess); - } catch (err) { - setError(String(err)); - } finally { - metadataProgressActiveRef.current = false; - setMetadataProgressActive(false); - setLoading(false); - } - }; - - const handleFetch = async () => { - await runFetch({}, {}, false); - }; - - const handleSourcePathDrop = async (paths: string[]) => { - if (loading) { - setError("Metadata fetch is already running."); - return; - } - const droppedPath = paths.find((candidate) => candidate.trim())?.trim() || ""; - if (!droppedPath) { - setError("Dropped file path was empty."); - return; - } - setError(""); - const selection = await handlePathSelected(droppedPath); - if (!selection || selection.waitsForPlaylistSelection) { - return; - } - await runFetch({}, {}, false, { - targetPath: selection.path, - targetMode: selection.mode, - }); - }; - - sourcePathDropHandlerRef.current = (paths: string[]) => { - void handleSourcePathDrop(paths); - }; - - useEffect(() => { - const runtime = ( - globalThis as typeof globalThis & { - runtime?: { OnFileDrop?: unknown; OnFileDropOff?: unknown }; - } - ).runtime; - if (browserMode || typeof runtime?.OnFileDrop !== "function") { - return; - } - OnFileDrop((_x, _y, paths) => { - sourcePathDropHandlerRef.current(paths); - }, true); - return () => { - if (typeof runtime.OnFileDropOff === "function") { - OnFileDropOff(); - } - }; - }, [browserMode]); - - const clearEditAttributesState = () => { - setIdEdits(buildIDEditState(emptyPreview.ExternalIDs)); - setIdTouched(buildIDTouchedState()); - setReleaseEdits(buildReleaseEditState({})); - setReleaseTouched(buildReleaseTouchedState({})); - }; - - const handleRefresh = async () => { - if ( - (!idOverrideState?.dirty && !releaseOverrideState?.dirty && !sourceLookupURL.trim()) || - idOverrideState?.invalid || - releaseOverrideState?.invalid - ) { - return; - } - await runFetch(idOverrideState?.overrides || {}, releaseOverrideState?.overrides || {}, true); - }; - - const handleResetMetadata = async () => { - setError(""); - const resetter = globalThis.go?.guiapp?.App?.ResetMetadata; - if (!resetter) { - setError("Metadata reset is unavailable in this build."); - return; - } - if (!path.trim()) { - setError("Please select a file or folder."); - return; - } - const selectedTrackers = selectedUploadImageTrackers; - if (selectedTrackers.length === 0 && selectedUploadTrackerEligibility.emptySelection) { - setError("Select at least one tracker before resetting metadata."); - return; - } - if ( - !globalThis.confirm( - "Remove cached metadata and temporary files for this content, then refetch metadata?", - ) - ) { - return; - } - const targetPath = path.trim(); - clearEditAttributesState(); - metadataProgressTargetRef.current = targetPath; - metadataProgressActiveRef.current = true; - setMetadataProgressUpdates([]); - setMetadataProgressActive(true); - setLoading(true); - setMetadataResetting(true); - try { - const result = await resetter(targetPath, sourceLookupURL.trim(), {}, {}, selectedTrackers); - applyPreviewResult(result); - setShowExternalIDInputUI(true); - } catch (err) { - setError(String(err)); - } finally { - metadataProgressActiveRef.current = false; - setMetadataProgressActive(false); - setLoading(false); - setMetadataResetting(false); - } - }; - - const handleSelectBlurayCandidate = async (releaseID: string) => { - setBluraySelectionError(""); - const selector = globalThis.go?.guiapp?.App?.SelectBlurayCandidate; - if (!selector) { - setBluraySelectionError("Blu-ray candidate selection is unavailable in this build."); - return; - } - const targetPath = (preview.SourcePath || path).trim(); - if (!targetPath || !releaseID.trim()) { - setBluraySelectionError("Path and release candidate are required."); - return; - } - setBluraySelecting(true); - try { - const result = await selector(targetPath, releaseID.trim()); - applyPreviewResult(result, { switchToInput: false }); - } catch (err) { - setBluraySelectionError(String(err)); - } finally { - setBluraySelecting(false); - } - }; - - const runDescriptionBuilder = useCallback( - async (overrides: ExternalIDOverrides, nameOverrides: ReleaseNameOverrides) => { - const clearBuilderProgressTimers = () => { - builderProgressTimers.current.forEach((timer) => window.clearTimeout(timer)); - builderProgressTimers.current = []; - }; - clearBuilderProgressTimers(); - setBuilderError(""); - setBuilderSaved(""); - setBuilderProgressMessage(""); - const fetcher = globalThis.go?.guiapp?.App?.FetchDescriptionBuilder; - if (!fetcher) { - setBuilderError("Description builder is unavailable in this build."); - return; - } - if (!path.trim()) { - setBuilderError("Please select a file or folder."); - return; - } - const selectedTrackers = selectedUploadImageTrackers; - if ( - selectedTrackers.length === 0 && - hasExplicitEmptyReleaseTrackerSelection(trackerUploadItems, releasePageTrackerSelection) - ) { - setBuilderError("Select at least one tracker before building descriptions."); - return; - } - if (selectedTrackers.length === 0) { - setBuilderError("Enable at least one tracker in Upload Targets."); - return; - } - setBuilderLoading(true); - setBuilderProgressMessage("Preparing metadata and tracker selection..."); - builderProgressTimers.current = [ - window.setTimeout( - () => setBuilderProgressMessage("Checking image-host requirements..."), - 900, - ), - window.setTimeout( - () => - setBuilderProgressMessage("Rehosting required comparison and description images..."), - 2500, - ), - window.setTimeout( - () => setBuilderProgressMessage("Still rehosting images and building descriptions..."), - 5000, - ), - window.setTimeout( - () => setBuilderProgressMessage("Large image upload still running..."), - 15000, - ), - window.setTimeout( - () => setBuilderProgressMessage("Waiting for image hosts to finish..."), - 30000, - ), - ]; - try { - const result = await fetcher( - path.trim(), - normalizeOverrides(overrides), - normalizeReleaseOverrides(nameOverrides), - selectedTrackers, - ignoredDupeTrackers, - ); - setBuilderPreview(result); - setBuilderRawByGroup( - Object.fromEntries( - (result.Groups || []).map((group) => [group.GroupKey, group.RawDescription || ""]), - ), - ); - setBuilderRenderedByGroup( - Object.fromEntries( - (result.Groups || []).map((group) => [group.GroupKey, group.RawDescriptionHTML || ""]), - ), - ); - setBuilderExpandedGroups((prev) => { - const next: Record = {}; - (result.Groups || []).forEach((group) => { - next[group.GroupKey] = prev[group.GroupKey] ?? false; - }); - return next; - }); - setBuilderDirtyByGroup({}); - clearBuilderProgressTimers(); - setBuilderProgressMessage("Refreshing uploaded image records..."); - await refreshUploadedImages(); - } catch (err) { - setBuilderError(String(err)); - } finally { - clearBuilderProgressTimers(); - setBuilderProgressMessage(""); - setBuilderLoading(false); - } - }, - [ - path, - releasePageTrackerSelection, - trackerUploadItems, - selectedUploadImageTrackers, - ignoredDupeTrackers, - refreshUploadedImages, - ], - ); - - const refreshDescriptionBuilder = useCallback(async () => { - if (builderDirty) { - const shouldRefresh = window.confirm( - "Refreshing descriptions will discard unsaved description edits. Continue?", - ); - if (!shouldRefresh) { - return; - } - } - - setBuilderRefreshing(true); - try { - await runDescriptionBuilder( - idOverrideState?.overrides || {}, - releaseOverrideState?.overrides || {}, - ); - } finally { - setBuilderRefreshing(false); - } - }, [builderDirty, idOverrideState, releaseOverrideState, runDescriptionBuilder]); - - useEffect(() => { - return () => { - builderProgressTimers.current.forEach((timer) => window.clearTimeout(timer)); - builderProgressTimers.current = []; - }; - }, []); - - const resetBuilderDescription = async ( - groupKey: string, - overrides: ExternalIDOverrides, - nameOverrides: ReleaseNameOverrides, - ) => { - setBuilderError(""); - setBuilderSaved(""); - const saver = globalThis.go?.guiapp?.App?.SaveDescriptionOverride; - if (!saver) { - setBuilderError("Description saving is unavailable in this build."); - return; - } - if (!path.trim()) { - setBuilderError("Please select a file or folder."); - return; - } - const currentGroup = (builderPreview.Groups || []).find((group) => group.GroupKey === groupKey); - if (!currentGroup) { - setBuilderError("Description group not found."); - return; - } - setBuilderLoading(true); - try { - const updatedGroup = await saver( - path.trim(), - groupKey, - "", - currentGroup.Trackers || [], - normalizeOverrides(overrides), - normalizeReleaseOverrides(nameOverrides), - ); - setBuilderPreview((prev) => upsertBuilderGroup(prev, updatedGroup)); - setBuilderRawByGroup((prev) => ({ ...prev, [groupKey]: updatedGroup.RawDescription || "" })); - setBuilderRenderedByGroup((prev) => ({ - ...prev, - [groupKey]: updatedGroup.RawDescriptionHTML || "", - })); - setBuilderDirtyByGroup((prev) => ({ ...prev, [groupKey]: false })); - setBuilderSaved("Description reset."); - } catch (err) { - setBuilderError(String(err)); - } finally { - setBuilderLoading(false); - } - }; - - const renderBuilderDescription = async (groupKey: string) => { - setBuilderError(""); - const renderer = globalThis.go?.guiapp?.App?.RenderDescription; - if (!renderer) { - setBuilderError("Description rendering is unavailable in this build."); - return; - } - const raw = builderRawByGroup[groupKey] || ""; - if (!raw.trim()) { - setBuilderRenderedByGroup((prev) => ({ ...prev, [groupKey]: "" })); - return; - } - setBuilderRenderLoading(true); - try { - const html = await renderer(raw); - setBuilderRenderedByGroup((prev) => ({ ...prev, [groupKey]: html || "" })); - } catch (err) { - setBuilderError(String(err)); - } finally { - setBuilderRenderLoading(false); - } - }; - - const saveBuilderDescription = async (groupKey: string) => { - setBuilderError(""); - setBuilderSaved(""); - const saver = globalThis.go?.guiapp?.App?.SaveDescriptionOverride; - if (!saver) { - setBuilderError("Description saving is unavailable in this build."); - return; - } - if (!path.trim()) { - setBuilderError("Please select a file or folder."); - return; - } - const currentGroup = (builderPreview.Groups || []).find((group) => group.GroupKey === groupKey); - if (!currentGroup) { - setBuilderError("Description group not found."); - return; - } - setBuilderSaving(true); - try { - const updatedGroup = await saver( - path.trim(), - groupKey, - builderRawByGroup[groupKey] || "", - currentGroup.Trackers || [], - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - ); - const nextPreview = upsertBuilderGroup(builderPreview, updatedGroup); - const shouldRefreshDryRun = - path.trim() === String(trackerDryRunPreview.SourcePath || "").trim() && - (trackerDryRunPreview.Trackers || []).length > 0; - - setBuilderPreview(nextPreview); - setBuilderRawByGroup((prev) => ({ ...prev, [groupKey]: updatedGroup.RawDescription || "" })); - setBuilderRenderedByGroup((prev) => ({ - ...prev, - [groupKey]: updatedGroup.RawDescriptionHTML || "", - })); - setBuilderSaved("Description saved."); - setBuilderDirtyByGroup((prev) => ({ ...prev, [groupKey]: false })); - - if (shouldRefreshDryRun) { - try { - await runTrackerDryRun(nextPreview.Groups || [], false); - setBuilderSaved("Description saved. Dry run refreshed."); - } catch (err) { - setTrackerDryRunError(`Description saved, but dry run refresh failed: ${String(err)}`); - } - } - } catch (err) { - setBuilderError(String(err)); - } finally { - setBuilderSaving(false); - } - }; - - const runScreenshotCapture = async ( - selections: ScreenshotSelection[], - purpose: ScreenshotPurpose, - ) => { - const runner = globalThis.go?.guiapp?.App?.GenerateScreenshots; - if (!runner) { - throw new Error("Screenshot capture is unavailable in this build."); - } - return runner( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selections, - purpose, - ); - }; - - const warmMetadataCache = async () => { - const fetcher = globalThis.go?.guiapp?.App?.FetchMetadata; - if (!fetcher) { - return; - } - if (!path.trim()) { - return; - } - const selectedTrackers = selectedUploadImageTrackers; - if (selectedTrackers.length === 0 && selectedUploadTrackerEligibility.emptySelection) { - return; - } - await fetcher( - path.trim(), - sourceLookupURL.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selectedTrackers, - ); - }; - - const previewFrameRate = useMemo(() => { - const rate = screenshots.screenshotPlan?.FrameRate || 0; - return rate > 0 ? rate : 24; - }, [screenshots.screenshotPlan]); - - const previewDuration = useMemo( - () => screenshots.screenshotPlan?.DurationSeconds || 0, - [screenshots.screenshotPlan], - ); - - const clampPreviewSeconds = useCallback( - (value: number) => { - if (!Number.isFinite(value)) return 0; - if (previewDuration > 0) { - return Math.min(Math.max(value, 0), previewDuration); - } - return Math.max(value, 0); - }, - [previewDuration], - ); - - const livePreviewFrame = useMemo(() => { - if (previewFrameRate <= 0) return 0; - const seconds = clampPreviewSeconds(livePreviewSeconds); - const frame = Math.round(seconds * previewFrameRate); - return Number.isFinite(frame) ? frame : 0; - }, [livePreviewSeconds, previewFrameRate, clampPreviewSeconds]); - - const runLivePreviewAt = async (timestampSeconds: number) => { - setLivePreviewError(""); - if (!path.trim()) { - setLivePreviewError("Please select a file or folder."); - return; - } - if (!screenshots.screenshotPlan) { - setLivePreviewError("Load suggestions to enable live preview."); - return; - } - - const previewer = globalThis.go?.guiapp?.App?.PreviewScreenshotFrame; - if (!previewer) { - setLivePreviewError("Live preview is unavailable in this build."); - return; - } - - const requestId = livePreviewRequestId.current + 1; - livePreviewRequestId.current = requestId; - setLivePreviewLoading(true); - const timestamp = clampPreviewSeconds(timestampSeconds); - try { - const dataUri = await previewer( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - timestamp, - ); - if (livePreviewRequestId.current !== requestId) { - return; - } - setLivePreviewImage(dataUri); - } catch (err) { - if (livePreviewRequestId.current !== requestId) { - return; - } - setLivePreviewError(String(err)); - } finally { - if (livePreviewRequestId.current === requestId) { - setLivePreviewLoading(false); - } - } - }; - - const runLivePreview = async () => { - await runLivePreviewAt(livePreviewSeconds); - }; - - const stepLivePreview = (direction: number) => { - const step = 1 / previewFrameRate; - const next = clampPreviewSeconds(livePreviewSeconds + direction * step); - setLivePreviewSeconds(next); - void runLivePreviewAt(next); - }; - - const handlePreviewSelection = async (selection: ScreenshotSelection) => { - screenshots.setScreenshotsError(""); - if (!path.trim()) { - screenshots.setScreenshotsError("Please select a file or folder."); - return; - } - screenshots.setPreviewLoadingIndex(selection.Index); - try { - const result = await runScreenshotCapture([selection], "preview"); - const images = result.Images || []; - const previews = await Promise.all(images.map(screenshots.readScreenshotImage)); - screenshots.setPreviewImages((prev) => { - const merged = new Map(); - prev.forEach((item) => { - if (item.image.Path) { - merged.set(item.image.Path, item); - } - }); - previews.forEach((item) => { - if (item.image.Path) { - merged.set(item.image.Path, item); - } - }); - return Array.from(merged.values()); - }); - } catch (err) { - screenshots.setScreenshotsError(String(err)); - } finally { - screenshots.setPreviewLoadingIndex(null); - } - }; - - const handleCapturePreviewFrame = async () => { - screenshots.setScreenshotsError(""); - if (!path.trim()) { - screenshots.setScreenshotsError("Please select a file or folder."); - return; - } - - const timestamp = clampPreviewSeconds(livePreviewSeconds); - const baseSelections = - screenshots.screenshotSelections.length > 0 - ? screenshots.screenshotSelections - : screenshots.screenshotPlan?.SuggestedSelections || []; - if (baseSelections.length === 0) { - screenshots.setScreenshotsError("No screenshot selections available."); - return; - } - - const autoSelections = baseSelections.filter((entry) => { - const source = (entry.Source || "auto").toLowerCase(); - return source === "auto"; - }); - const candidates = autoSelections.length > 0 ? autoSelections : baseSelections; - - const resolveTimestamp = (entry: ScreenshotSelection) => { - if (Number.isFinite(entry.TimestampSeconds) && entry.TimestampSeconds > 0) { - return entry.TimestampSeconds; - } - if (Number.isFinite(entry.Frame) && entry.Frame > 0) { - return entry.Frame / previewFrameRate; - } - return 0; - }; - - const closest = candidates.reduce( - (best, entry) => { - const currentDiff = Math.abs(resolveTimestamp(entry) - timestamp); - if (!best) return entry; - const bestDiff = Math.abs(resolveTimestamp(best) - timestamp); - if (currentDiff < bestDiff) return entry; - return best; - }, - undefined as ScreenshotSelection | undefined, - ); - - if (!closest) { - screenshots.setScreenshotsError("No screenshot selections available."); - return; - } - - const frame = Math.max(0, Math.round(timestamp * previewFrameRate)); - const selection: ScreenshotSelection = { - Index: closest.Index, - TimestampSeconds: timestamp, - Frame: frame, - Source: "manual", - }; - - const updatedSelections = baseSelections.map((entry) => - entry.Index === selection.Index ? selection : entry, - ); - const regenerated = regenerateAutoSelections(updatedSelections); - const reindexed = reindexSelectionsByTimestamp(regenerated, selection.Index); - const tolerance = previewFrameRate > 0 ? 1 / previewFrameRate : 0.01; - const manualSelection = reindexed.selections.find((entry) => { - const source = (entry.Source || "").toLowerCase(); - if (source !== "manual") return false; - const ts = resolveTimestamp(entry); - if (!Number.isFinite(ts) || ts <= 0) return false; - return Math.abs(ts - timestamp) <= tolerance; - }); - const resolvedSelection = - manualSelection || - (reindexed.targetIndex >= 0 ? reindexed.selections[reindexed.targetIndex] : undefined); - if (!resolvedSelection) { - screenshots.setScreenshotsError("Failed to resolve capture index."); - return; - } - screenshots.setScreenshotSelections(reindexed.selections); - - const captureSelection: ScreenshotSelection = { - ...selection, - Index: resolvedSelection.Index, - }; - - setLiveCaptureLoading(true); - try { - const result = await runScreenshotCapture([captureSelection], "preview"); - const images = result.Images || []; - const previews = await Promise.all(images.map(screenshots.readScreenshotImage)); - screenshots.setPreviewImages((prev) => { - const merged = new Map(); - prev.forEach((item) => { - if (item.image.Path) { - merged.set(item.image.Path, item); - } - }); - previews.forEach((item) => { - if (item.image.Path) { - merged.set(item.image.Path, item); - } - }); - return Array.from(merged.values()); - }); - if (previews.length > 0) { - const mergedFinals = mergeFinalSelections(screenshots.finalImagesRef.current, previews); - await screenshots.saveFinalSelections(mergedFinals); - } - } catch (err) { - screenshots.setScreenshotsError(String(err)); - } finally { - setLiveCaptureLoading(false); - } - }; - - const buildExistingSelectionIndexSet = () => { - const indices = new Set(); - const addImages = (images: ScreenshotImage[] | undefined) => { - if (!images || images.length === 0) return; - images.forEach((image) => { - if (Number.isFinite(image.Index)) { - indices.add(image.Index); - } - }); - }; - addImages(screenshots.screenshotPlan?.ExistingScreenshots); - addImages(screenshots.existingImages.map((entry) => entry.image)); - addImages(screenshots.finalImagesRef.current.map((entry) => entry.image)); - return indices; - }; - - const handleGenerateScreenshots = async () => { - screenshots.setScreenshotsError(""); - if (!path.trim()) { - screenshots.setScreenshotsError("Please select a file or folder."); - return; - } - let selections = screenshots.screenshotSelections; - if (selections.length === 0) { - const plan = await screenshots.loadScreenshotPlan(false); - selections = plan?.SuggestedSelections || []; - } - if (selections.length === 0) { - screenshots.setScreenshotsError("No screenshot selections available."); - return; - } - const existingIndices = buildExistingSelectionIndexSet(); - const filteredSelections = selections.filter((entry) => !existingIndices.has(entry.Index)); - if (filteredSelections.length === 0) { - screenshots.setScreenshotsError("All requested screenshots already exist."); - return; - } - screenshots.setScreenshotsLoading(true); - try { - const result = await runScreenshotCapture(filteredSelections, "final"); - screenshots.setFinalResult(result); - const images = result.Images || []; - const previews = await Promise.all(images.map(screenshots.readScreenshotImage)); - const merged = mergeFinalSelections(screenshots.finalImagesRef.current, previews); - await screenshots.saveFinalSelections(merged); - } catch (err) { - screenshots.setScreenshotsError(String(err)); - } finally { - screenshots.setScreenshotsLoading(false); - } - }; - - const applyDupeCheckSnapshot = useCallback((snapshot: DupeCheckSnapshot) => { - setDupeCheckSnapshot(snapshot); - setDupeSummary(snapshot.summary || emptyDupeSummary); - - const normalized = normalizeJobStatus(snapshot.status); - const running = isRunningJobStatus(normalized); - setDupeLoading(running); - - if (normalized === "completed") { - setDupeChecked(true); - setDupeError(""); - } else if (normalized === "completed_with_errors") { - setDupeChecked(true); - setDupeError(snapshot.error || "One or more tracker dupe checks failed."); - } else if (normalized === "failed" || normalized === "canceled") { - setDupeChecked(false); - setDupeError(snapshot.error ? dupeCheckErrorMessage(snapshot.error) : "Dupe check failed."); - } - }, []); - - const handleDupeCheck = async () => { - setDupeError(""); - const starter = globalThis.go?.guiapp?.App?.StartDupeCheck; - const snapshotLoader = globalThis.go?.guiapp?.App?.GetDupeCheckSnapshot; - if (!starter) { - setDupeError("Dupe checking is unavailable in this build."); - return; - } - if (!path.trim()) { - setDupeError("Please select a file or folder."); - return; - } - if (idOverrideState?.invalid || releaseOverrideState?.invalid) { - setDupeError("Fix invalid overrides before checking dupes."); - return; - } - const trackerEligibility = resolveUploadTrackerEligibilityForPath(path, { - applyDupeFilters: false, - }); - const selectedTrackers = trackerEligibility.selectedTrackers.slice(); - if (selectedTrackers.length === 0) { - setDupeError("Select at least one tracker before checking dupes."); - return; - } - setDupeLoading(true); - let jobID = ""; - try { - jobID = await starter( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selectedTrackers, - ); - } catch (err) { - const message = String(err); - // Starter failures do not create a durable job, so stop polling without - // discarding the last completed summary. - setDupeLoading(false); - setDupeCheckJobID(""); - setDupeError(dupeCheckErrorMessage(message)); - return; - } - setDupeChecked(false); - setDupeSummary(emptyDupeSummary); - setDupeCheckSnapshot(null); - setDupeCheckJobID(jobID); - // The first snapshot is best-effort; once a job exists, events and fallback - // polling own lifecycle updates. - if (snapshotLoader) { - try { - const snapshot = await snapshotLoader(jobID); - applyDupeCheckSnapshot(snapshot); - } catch { - // Keep tracking the job even when this initial fetch is transiently unavailable. - } - } - }; - - useEffect(() => { - if (!dupeCheckJobID) { - return; - } - - const eventName = `${dupeCheckEventPrefix}${dupeCheckJobID}`; - const off = EventsOn(eventName, (payload: any) => { - if (payload?.jobID !== dupeCheckJobID) { - return; - } - applyDupeCheckSnapshot(payload as DupeCheckSnapshot); - }); - - return () => { - if (typeof off === "function") { - off(); - } - }; - }, [applyDupeCheckSnapshot, dupeCheckJobID]); - - useEffect(() => { - // Poll active jobs as a fallback for missed early job events; live events - // still drive updates when the runtime stream is healthy. - const currentStatus = String(dupeCheckSnapshot?.status || ""); - if (!dupeCheckJobID || (!dupeLoading && !isRunningJobStatus(currentStatus))) { - return; - } - const snapshotLoader = globalThis.go?.guiapp?.App?.GetDupeCheckSnapshot; - if (!snapshotLoader) { - return; - } - - let stopped = false; - let timer: number | undefined; - const loadSnapshot = async () => { - try { - const snapshot = await snapshotLoader(dupeCheckJobID); - if (!stopped) { - applyDupeCheckSnapshot(snapshot); - } - } catch { - // Event delivery remains primary; transient polling failures should not replace UI errors. - } - if (!stopped) { - timer = window.setTimeout(loadSnapshot, 1000); - } - }; - - timer = window.setTimeout(loadSnapshot, 1000); - return () => { - stopped = true; - if (timer !== undefined) { - window.clearTimeout(timer); - } - }; - }, [applyDupeCheckSnapshot, dupeCheckJobID, dupeCheckSnapshot?.status, dupeLoading]); - - useEffect(() => { - setCurrentDiscType(""); - setImageAssetsRevision(0); - setDupeChecked(false); - setDupeCheckJobID(""); - setDupeCheckSnapshot(null); - setDupeSummary(emptyDupeSummary); - setDupeIgnore({}); - setDupeTrackerFlags({}); - autoDisabledUploadTrackersRef.current.clear(); - setBuilderPreview(emptyDescriptionBuilder); - setBuilderRawByGroup({}); - setBuilderRenderedByGroup({}); - setBuilderExpandedGroups({}); - setBuilderError(""); - setBuilderDirtyByGroup({}); - setBuilderSaved(""); - setBuilderRefreshing(false); - setBuilderAutoRequestKey(""); - setTrackerUploadRunning(false); - setTrackerUploadError(""); - setTrackerUploadJobID(""); - setTrackerUploadSnapshot(null); - setTrackerDryRunLoading(false); - setTrackerDryRunError(""); - setTrackerDryRunPreview(emptyTrackerDryRun); - setTrackerDryRunProgress(null); - bdinfoProgressActiveRef.current = false; - metadataProgressTargetRef.current = ""; - metadataProgressActiveRef.current = false; - setMetadataProgressActive(false); - setMetadataProgressUpdates([]); - resetScreenshotState(); - }, [path, resetScreenshotState]); - - useEffect(() => { - if (activeTab !== "description_builder") return; - if (!dupeChecked) return; - if (builderLoading || builderSaving) return; - if (builderDirty) return; - const normalizedPath = path.trim(); - if (!normalizedPath) return; - const requestKey = JSON.stringify({ - path: normalizedPath, - external: normalizeOverrides(idOverrideState?.overrides || {}), - release: normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - imageAssetsRevision, - }); - if (builderAutoRequestKey === requestKey) return; - setBuilderAutoRequestKey(requestKey); - runDescriptionBuilder(idOverrideState?.overrides || {}, releaseOverrideState?.overrides || {}); - }, [ - activeTab, - dupeChecked, - builderLoading, - builderSaving, - builderDirty, - path, - idOverrideState, - releaseOverrideState, - builderAutoRequestKey, - imageAssetsRevision, - runDescriptionBuilder, - ]); - - useEffect(() => { - if (activeTab !== "upload") return; - if (builderReady) return; - setActiveTab("description_builder"); - }, [activeTab, builderReady]); - - useEffect(() => { - if (activeTab !== "screenshots") return; - if (!dupeChecked) return; - if (screenshots.screenshotPlan || screenshots.screenshotsLoading) return; - loadScreenshotPlan(); - }, [ - activeTab, - dupeChecked, - screenshots.screenshotPlan, - screenshots.screenshotsLoading, - loadScreenshotPlan, - ]); - - useEffect(() => { - if (activeTab !== "upload_images") return; - if (!dupeChecked) return; - if (screenshots.screenshotPlan || screenshots.screenshotsLoading) return; - loadScreenshotPlan(); - }, [ - activeTab, - dupeChecked, - screenshots.screenshotPlan, - screenshots.screenshotsLoading, - loadScreenshotPlan, - ]); - - useEffect(() => { - if (activeTab !== "upload_images") return; - if (!path.trim()) return; - const loadUploadCandidates = async () => { - try { - const candidates = await globalThis.go?.guiapp?.App?.ListUploadCandidates( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - ); - if (!candidates || candidates.length === 0) { - setExistingImages([]); - await refreshUploadedImages(); - return; - } - const previews = await Promise.all( - candidates.map(async (image: ScreenshotImage) => { - try { - return await readScreenshotImage(image); - } catch { - return null; - } - }), - ); - setExistingImages( - previews.filter((entry): entry is ScreenshotPreviewImage => Boolean(entry)), - ); - await refreshUploadedImages(); - } catch (err) { - console.error("Failed to load upload candidates:", err); - } - }; - loadUploadCandidates(); - }, [ - activeTab, - path, - idOverrideState, - releaseOverrideState, - setExistingImages, - readScreenshotImage, - refreshUploadedImages, - imageAssetsRevision, - ]); - - /** - * Synchronizes upload toggles with tracker selection and dupe eligibility. - * - * Dupe hits temporarily turn upload targets off. When the user ignores those - * dupes, the previous selected/default state is restored; hard failures and - * rule skips stay disabled until the blocking result changes. - */ - useEffect(() => { - if (trackerUploadItems.length === 0) return; - const next = { ...uploadToggles }; - const autoDisabled = new Set(autoDisabledUploadTrackersRef.current); - let changed = false; - const setToggle = (name: string, value: boolean) => { - if (next[name] !== value) { - next[name] = value; - changed = true; - } - }; - const deleteToggle = (name: string) => { - if (Object.prototype.hasOwnProperty.call(next, name)) { - delete next[name]; - changed = true; - } - }; - - trackerUploadItems.forEach((item) => { - const normalized = item.name.toLowerCase().trim(); - const hasReleaseSelection = Object.prototype.hasOwnProperty.call( - releasePageTrackerSelection, - item.name, - ); - if (hasReleaseSelection && !releasePageTrackerSelection[item.name]) { - autoDisabled.delete(normalized); - deleteToggle(item.name); - return; - } - if (dupeFiltersMatchCurrentPath && failedDupeTrackerSet.has(normalized)) { - autoDisabled.delete(normalized); - setToggle(item.name, false); - return; - } - if (dupeFiltersMatchCurrentPath && dupedTrackerSet.has(normalized)) { - if (next[item.name] !== false) { - autoDisabled.add(normalized); - } - setToggle(item.name, false); - return; - } - if (dupeFiltersMatchCurrentPath && ruleSkippedTrackerSet.has(normalized)) { - autoDisabled.delete(normalized); - setToggle(item.name, false); - return; - } - if (dupeFiltersMatchCurrentPath && skippedDupeTrackerSet.has(normalized)) { - autoDisabled.delete(normalized); - setToggle(item.name, false); - return; - } - if (autoDisabled.has(normalized)) { - setToggle( - item.name, - hasReleaseSelection - ? releasePageTrackerSelection[item.name] - : defaultTrackerSet.has(normalized), - ); - autoDisabled.delete(normalized); - return; - } - if (next[item.name] === undefined) { - // Prioritize release page selection, then fall back to defaults - if (hasReleaseSelection) { - setToggle(item.name, releasePageTrackerSelection[item.name]); - } else { - setToggle(item.name, defaultTrackerSet.has(normalized)); - } - } - }); - const trackerNames = new Set(trackerUploadItems.map((item) => item.name)); - Object.keys(next).forEach((name) => { - if (!trackerNames.has(name)) { - autoDisabled.delete(name.toLowerCase().trim()); - deleteToggle(name); - return; - } - if ( - Object.prototype.hasOwnProperty.call(releasePageTrackerSelection, name) && - !releasePageTrackerSelection[name] - ) { - autoDisabled.delete(name.toLowerCase().trim()); - deleteToggle(name); - } - }); - autoDisabledUploadTrackersRef.current = autoDisabled; - if (changed) { - setUploadToggles(next); - } - }, [ - trackerUploadItems, - defaultTrackerSet, - dupeFiltersMatchCurrentPath, - dupedTrackerSet, - ruleSkippedTrackerSet, - skippedDupeTrackerSet, - failedDupeTrackerSet, - releasePageTrackerSelection, - uploadToggles, - ]); - - useEffect(() => { - if (screenshots.uploadCandidates.length === 0) { - setUploadSelections({}); - return; - } - setUploadSelections((prev) => { - const next: Record = { ...prev }; - screenshots.uploadCandidates.forEach((item) => { - const pathValue = item.image.Path; - if (!pathValue) return; - if (next[pathValue] === undefined) { - next[pathValue] = true; - } - }); - Object.keys(next).forEach((key) => { - if (!uploadCandidatePaths.has(key)) { - delete next[key]; - } - }); - return next; - }); - }, [screenshots.uploadCandidates, uploadCandidatePaths, setUploadSelections]); - - useEffect(() => { - if (uploadHost || configuredImageHosts.length === 0) return; - setUploadHost(configuredImageHosts[0]); - }, [configuredImageHosts, uploadHost, setUploadHost]); - - // Initialize release page tracker selection when preview loads or trackers change - useEffect(() => { - if (trackerUploadItems.length === 0) { - setReleasePageTrackerSelection({}); - return; - } - setReleasePageTrackerSelection((prev) => { - const next = { ...prev }; - trackerUploadItems.forEach((item) => { - const normalized = item.name.toLowerCase(); - if (next[item.name] === undefined) { - // Initialize from defaults - next[item.name] = defaultTrackerSet.has(normalized); - } - }); - // Remove trackers no longer in the list - const trackerNames = trackerUploadItems.map((item) => item.name); - Object.keys(next).forEach((name) => { - if (!trackerNames.includes(name)) { - delete next[name]; - } - }); - return next; - }); - }, [trackerUploadItems, defaultTrackerSet]); - - useEffect(() => { - setTrackerQuestionnaireAnswers({}); - setTrackerDryRunPreview(emptyTrackerDryRun); - setTrackerDryRunError(""); - setTrackerDryRunProgress(null); - }, [path]); - - const getSelectedUploadTrackers = useCallback( - () => selectedUploadImageTrackers, - [selectedUploadImageTrackers], - ); - - const updateTrackerQuestionnaireAnswer = useCallback( - (tracker: string, key: string, value: string) => { - setTrackerQuestionnaireAnswers((prev) => { - const trackerKey = tracker.toUpperCase().trim(); - return { - ...prev, - [trackerKey]: { - ...(prev[trackerKey] || {}), - [key]: value, - }, - }; - }); - }, - [], - ); - - /** Applies an upload job snapshot from either live events or polling fallback. */ - const applyTrackerUploadSnapshot = useCallback((snapshot: TrackerUploadSnapshot) => { - setTrackerUploadSnapshot(snapshot); - const normalized = normalizeJobStatus(snapshot.status); - const running = isRunningJobStatus(normalized); - setTrackerUploadRunning(running); - if (normalized === "completed") { - setTrackerUploadError(""); - } else if ( - normalized === "completed_with_errors" || - normalized === "failed" || - normalized === "canceled" - ) { - setTrackerUploadError(snapshot.error || "Upload finished with errors."); - } - }, []); - - const handleStartTrackerUpload = useCallback(async () => { - setTrackerUploadError(""); - const starter = globalThis.go?.guiapp?.App?.StartTrackerUpload; - const snapshotLoader = globalThis.go?.guiapp?.App?.GetTrackerUploadSnapshot; - if (!starter) { - setTrackerUploadError("Tracker upload is unavailable in this build."); - return; - } - if (!path.trim()) { - setTrackerUploadError("Please select a file or folder."); - return; - } - if (idOverrideState?.invalid || releaseOverrideState?.invalid) { - setTrackerUploadError("Fix invalid overrides before uploading."); - return; - } - const selectedTrackers = getSelectedUploadTrackers(); - if (selectedTrackers.length === 0) { - setTrackerUploadError("Enable at least one tracker in Upload Targets."); - return; - } - const missingRequiredFields: string[] = []; - selectedTrackers.forEach((tracker) => { - const dryRunEntry = (trackerDryRunPreview.Trackers || []).find( - (entry) => - String(entry?.Tracker || "") - .toLowerCase() - .trim() === tracker.toLowerCase().trim(), - ); - const questionnaire = dryRunEntry?.Questionnaire; - if (!questionnaire?.Fields?.length) { - return; - } - const trackerAnswers = buildQuestionnaireAnswerDefaults( - questionnaire, - trackerQuestionnaireAnswers[tracker.toUpperCase().trim()], - ); - questionnaire.Fields.forEach((field) => { - if (field.Required && !String(trackerAnswers[field.Key] || "").trim()) { - missingRequiredFields.push(`${tracker}: ${field.Label || field.Key}`); - } - }); - }); - if (missingRequiredFields.length > 0) { - setTrackerUploadError( - `Complete required questionnaire fields before uploading: ${missingRequiredFields.join(", ")}`, - ); - return; - } - setTrackerUploadRunning(true); - setTrackerUploadSnapshot(null); - let jobID = ""; - try { - jobID = await starter( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selectedTrackers, - ignoredDupeTrackers, - cloneQuestionnaireAnswers(trackerQuestionnaireAnswers), - builderPreview.Groups || [], - runDebug, - uploadSkipClientInjection, - runLogLevel, - ); - } catch (err) { - setTrackerUploadRunning(false); - setTrackerUploadError(String(err)); - return; - } - setTrackerUploadJobID(jobID); - if (snapshotLoader) { - try { - const snapshot = await snapshotLoader(jobID); - applyTrackerUploadSnapshot(snapshot); - } catch { - // Events and polling continue tracking the started job when bootstrap refresh fails. - } - } - }, [ - path, - idOverrideState, - releaseOverrideState, - getSelectedUploadTrackers, - ignoredDupeTrackers, - trackerDryRunPreview, - trackerQuestionnaireAnswers, - builderPreview, - runDebug, - uploadSkipClientInjection, - runLogLevel, - applyTrackerUploadSnapshot, - ]); - - const runTrackerDryRun = useCallback( - async (descriptionGroups: DescriptionBuilderPreview["Groups"], surfaceError = true) => { - if (surfaceError) { - setTrackerDryRunError(""); - } - const fetcher = globalThis.go?.guiapp?.App?.FetchTrackerDryRun; - if (!fetcher) { - const message = "Tracker dry run is unavailable in this build."; - if (surfaceError) { - setTrackerDryRunError(message); - return null; - } - throw new Error(message); - } - if (!path.trim()) { - const message = "Please select a file or folder."; - if (surfaceError) { - setTrackerDryRunError(message); - return null; - } - throw new Error(message); - } - if (idOverrideState?.invalid || releaseOverrideState?.invalid) { - const message = "Fix invalid overrides before running dry run."; - if (surfaceError) { - setTrackerDryRunError(message); - return null; - } - throw new Error(message); - } - const selectedTrackers = getSelectedUploadTrackers(); - if (selectedTrackers.length === 0) { - const message = "Enable at least one tracker in Upload Targets."; - if (surfaceError) { - setTrackerDryRunError(message); - return null; - } - throw new Error(message); - } - - setTrackerDryRunLoading(true); - setTrackerDryRunProgress({ - sourcePath: path.trim(), - tracker: "", - task: "dry_run", - status: "running", - message: "Starting dry run", - completedPieces: 0, - totalPieces: 0, - percent: 0, - hashRateMiB: 0, - timestamp: new Date().toISOString(), - }); - try { - const result = await fetcher( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selectedTrackers, - ignoredDupeTrackers, - cloneQuestionnaireAnswers(trackerQuestionnaireAnswers), - descriptionGroups, - runDebug, - uploadSkipClientInjection, - runLogLevel, - ); - setTrackerDryRunPreview(result || emptyTrackerDryRun); - setTrackerQuestionnaireAnswers((prev) => { - const next = cloneQuestionnaireAnswers(prev); - (result?.Trackers || []).forEach((entry) => { - const trackerKey = String(entry?.Tracker || "") - .toUpperCase() - .trim(); - if (!trackerKey) { - return; - } - next[trackerKey] = buildQuestionnaireAnswerDefaults( - entry.Questionnaire, - next[trackerKey], - ); - }); - return next; - }); - return result || emptyTrackerDryRun; - } catch (err) { - if (surfaceError) { - setTrackerDryRunError(String(err)); - return null; - } - throw err; - } finally { - setTrackerDryRunLoading(false); - } - }, - [ - path, - idOverrideState, - releaseOverrideState, - getSelectedUploadTrackers, - ignoredDupeTrackers, - trackerQuestionnaireAnswers, - runDebug, - uploadSkipClientInjection, - runLogLevel, - ], - ); - - const handleRunTrackerDryRun = useCallback(async () => { - await runTrackerDryRun(builderPreview.Groups || []); - }, [builderPreview, runTrackerDryRun]); - - useEffect(() => { - const off = EventsOn(trackerUploadProgressEvent, (payload: any) => { - const update = payload as UploadProgressUpdate; - const updatePath = String(update?.sourcePath || "").trim(); - if (updatePath && updatePath !== path.trim()) { - return; - } - setTrackerDryRunProgress(update); - }); - - return () => { - if (typeof off === "function") { - off(); - } - }; - }, [path]); - - const handleCancelTrackerUpload = useCallback(async () => { - setTrackerUploadError(""); - if (!trackerUploadJobID) { - return; - } - const cancel = globalThis.go?.guiapp?.App?.CancelTrackerUpload; - if (!cancel) { - setTrackerUploadError("Tracker upload cancel is unavailable in this build."); - return; - } - try { - await cancel(trackerUploadJobID); - } catch (err) { - setTrackerUploadError(String(err)); - } - }, [trackerUploadJobID]); - - const handleRetryFailedTrackerUpload = useCallback(async () => { - setTrackerUploadError(""); - if (!trackerUploadJobID) { - return; - } - const retry = globalThis.go?.guiapp?.App?.RetryFailedTrackerUpload; - const snapshotLoader = globalThis.go?.guiapp?.App?.GetTrackerUploadSnapshot; - if (!retry) { - setTrackerUploadError("Tracker retry is unavailable in this build."); - return; - } - setTrackerUploadRunning(true); - let nextJobID = ""; - try { - nextJobID = await retry(trackerUploadJobID); - } catch (err) { - setTrackerUploadRunning(false); - setTrackerUploadError(String(err)); - return; - } - setTrackerUploadJobID(nextJobID); - setTrackerUploadSnapshot(null); - if (snapshotLoader) { - try { - const snapshot = await snapshotLoader(nextJobID); - applyTrackerUploadSnapshot(snapshot); - } catch { - // Events and polling continue tracking the replacement job when bootstrap refresh fails. - } - } - }, [applyTrackerUploadSnapshot, trackerUploadJobID]); - - useEffect(() => { - if (!trackerUploadJobID) { - return; - } - - const eventName = `${trackerUploadEventPrefix}${trackerUploadJobID}`; - const off = EventsOn(eventName, (payload: any) => { - if (payload?.jobID !== trackerUploadJobID) { - return; - } - applyTrackerUploadSnapshot(payload as TrackerUploadSnapshot); - }); - - return () => { - if (typeof off === "function") { - off(); - } - }; - }, [applyTrackerUploadSnapshot, trackerUploadJobID]); - - useEffect(() => { - // Poll active upload jobs as a fallback for missed upload snapshot events. - const currentStatus = String(trackerUploadSnapshot?.status || ""); - if (!trackerUploadJobID || (!trackerUploadRunning && !isRunningJobStatus(currentStatus))) { - return; - } - const snapshotLoader = globalThis.go?.guiapp?.App?.GetTrackerUploadSnapshot; - if (!snapshotLoader) { - return; - } - - let stopped = false; - let timer: number | undefined; - const loadSnapshot = async () => { - try { - const snapshot = await snapshotLoader(trackerUploadJobID); - if (!stopped) { - applyTrackerUploadSnapshot(snapshot); - } - } catch { - // Event delivery remains primary; transient polling failures should not replace UI errors. - } - if (!stopped) { - timer = window.setTimeout(loadSnapshot, 1000); - } - }; - - timer = window.setTimeout(loadSnapshot, 1000); - return () => { - stopped = true; - if (timer !== undefined) { - window.clearTimeout(timer); - } - }; - }, [ - applyTrackerUploadSnapshot, - trackerUploadJobID, - trackerUploadRunning, - trackerUploadSnapshot?.status, - ]); - - const markReleaseTouched = (key: keyof ReleaseNameTouchedState) => { - setReleaseTouched((prev) => ({ ...prev, [key]: true })); - }; - - const applyScreenshotSettings = async () => { - screenshots.setScreenshotsError(""); - clearSettingsStatus(); - const saveConfig = globalThis.go?.guiapp?.App?.SaveConfig; - if (!saveConfig) { - screenshots.setScreenshotsError("Settings are unavailable in this build."); - return; - } - const payload = buildSavePayload(); - if (!payload) { - screenshots.setScreenshotsError("Settings are not loaded."); - return; - } - setScreenshotsSettingsSaving(true); - try { - await saveConfig(payload); - markSettingsSaved("Settings saved and applied."); - await warmMetadataCache(); - await screenshots.loadScreenshotPlan(); - } catch (err) { - screenshots.setScreenshotsError(String(err)); - } finally { - setScreenshotsSettingsSaving(false); - } - }; - - const showConfigOpStatus = useCallback((status: NonNullable) => { - if (configOpTimerRef.current) clearTimeout(configOpTimerRef.current); - setConfigOpStatus(status); - if (status.type === "success") { - configOpTimerRef.current = setTimeout(() => setConfigOpStatus(null), 8000); - } - }, []); - - const dismissConfigOpStatus = useCallback(() => { - if (configOpTimerRef.current) clearTimeout(configOpTimerRef.current); - setConfigOpStatus(null); - }, []); - - useEffect(() => { - return () => { - if (configOpTimerRef.current) clearTimeout(configOpTimerRef.current); - }; - }, []); - - const handleExportSettings = async () => { - clearSettingsStatus(); - dismissConfigOpStatus(); - const exportConfig = globalThis.go?.guiapp?.App?.ExportConfig; - if (!exportConfig) { - showConfigOpStatus({ - type: "error", - title: "Export Failed", - message: "Settings export is unavailable in this build.", - }); - return; - } - - setSettingsExporting(true); - try { - const exportedPath = await exportConfig(); - if (exportedPath?.trim()) { - showConfigOpStatus({ - type: "success", - title: "Configuration Exported", - message: `Saved to ${exportedPath}`, - }); - } - } catch (err) { - showConfigOpStatus({ type: "error", title: "Export Failed", message: String(err) }); - } finally { - setSettingsExporting(false); - } - }; - - const handleImportConfigRequest = () => { - clearSettingsStatus(); - dismissConfigOpStatus(); - setImportConfirmOpen(true); - }; - - const handleImportConfigCancel = () => { - if (settingsImporting) return; - setImportConfirmOpen(false); - }; - - const handleImportConfigConfirm = async () => { - const importConfig = globalThis.go?.guiapp?.App?.ImportConfig; - if (!importConfig) { - setImportConfirmOpen(false); - showConfigOpStatus({ - type: "error", - title: "Import Failed", - message: "Config import is unavailable in this build.", - }); - return; - } - - setSettingsImporting(true); - try { - const result = await importConfig(); - const message = (result?.message ?? "").trim(); - if (!message) { - return; - } - const warnings = result?.warnings ?? []; - if (warnings.length > 0) { - showConfigOpStatus({ type: "warning", title: "Imported with Warnings", message, warnings }); - } else { - showConfigOpStatus({ type: "success", title: "Configuration Imported", message }); - } - loadSettings(); - } catch (err) { - showConfigOpStatus({ type: "error", title: "Import Failed", message: String(err) }); - } finally { - setSettingsImporting(false); - setImportConfirmOpen(false); - } - }; - - const loadWebAuthStatus = useCallback(async () => { - const getWebAuthStatus = globalThis.go?.guiapp?.App?.GetWebAuthStatus; - if (!getWebAuthStatus) { - setWebAuthStatus(null); - setWebAuthError(""); - return; - } - - setWebAuthLoading(true); - setWebAuthError(""); - try { - const status = await getWebAuthStatus(); - setWebAuthStatus(status); - } catch (err) { - setWebAuthStatus({ ...emptyWebAuthStatus, message: "Unable to load web auth status." }); - setWebAuthError(String(err)); - } finally { - setWebAuthLoading(false); - } - }, []); - - const handleCreateWebAuth = useCallback(async () => { - clearSettingsStatus(); - dismissConfigOpStatus(); - setWebAuthError(""); - - if (webAuthPassword !== webAuthConfirm) { - setWebAuthError("Passwords do not match."); - return; - } - - const createWebAuth = globalThis.go?.guiapp?.App?.CreateWebAuth; - if (!createWebAuth) { - setWebAuthError("Web auth bootstrap is unavailable in this build."); - return; - } - - setWebAuthCreating(true); - try { - const status = await createWebAuth(webAuthUsername, webAuthPassword); - setWebAuthStatus(status); - setWebAuthPassword(""); - setWebAuthConfirm(""); - markSettingsSaved("Web auth created. Future secret saves and exports can use encryption."); - } catch (err) { - setWebAuthError(String(err)); - } finally { - setWebAuthCreating(false); - } - }, [ - clearSettingsStatus, - dismissConfigOpStatus, - markSettingsSaved, - webAuthConfirm, - webAuthPassword, - webAuthUsername, - ]); - - useEffect(() => { - if (activeTab !== "settings") { - return; - } - loadWebAuthStatus(); - }, [activeTab, loadWebAuthStatus]); - - const dupeProgressStatus = String(dupeCheckSnapshot?.status || "").toLowerCase(); - const dupeCompletedCount = Number(dupeCheckSnapshot?.completedCount || 0); - const dupeTotalCount = Number(dupeCheckSnapshot?.totalCount || 0); - - return ( -
-
-
-
- - -
- {showPlaylistSelection ? ( - { - setShowPlaylistSelection(false); - setPlaylistPreparationTrackerSnapshot(null); - }} - onConfirm={handlePlaylistSelectionComplete} - preparing={playlistAutoPreparing} - progressLines={bdinfoProgressLines} - progressError={playlistPreparationError} - /> - ) : activeTab === "settings" ? ( - - ) : activeTab === "logging" ? ( - - ) : activeTab === "history" ? ( - - ) : activeTab === "dupes" ? ( - - ) : activeTab === "screenshots" ? ( - - ) : activeTab === "menu_images" ? ( - setActiveTab("upload_images")} - setLightboxImage={setLightboxImage} - setLightboxAlt={setLightboxAlt} - /> - ) : activeTab === "upload_images" ? ( - - ) : activeTab === "bluray" ? ( - void handleSelectBlurayCandidate(releaseID)} - setLightboxImage={setLightboxImage} - setLightboxAlt={setLightboxAlt} - /> - ) : activeTab === "description_builder" ? ( - - resetBuilderDescription( - groupKey, - idOverrideState?.overrides || {}, - releaseOverrideState?.overrides || {}, - ) - } - renderBuilderDescription={renderBuilderDescription} - saveBuilderDescription={saveBuilderDescription} - /> - ) : activeTab === "upload" ? ( - - ) : activeTab === "tracker" && hasTrackerData ? ( - - ) : ( - - setIdTouched((previous) => ({ - ...previous, - [key]: true, - })) - } - releaseEdits={releaseEdits} - setReleaseEdits={setReleaseEdits} - markReleaseTouched={markReleaseTouched} - idOverrideState={idOverrideState} - releaseOverrideState={releaseOverrideState} - showExternalIDInputUI={showExternalIDInputUI} - refreshDisabled={refreshDisabled} - selectedProvider={selectedProvider} - setSelectedProvider={setSelectedProvider} - setLightboxImage={setLightboxImage} - setLightboxAlt={setLightboxAlt} - runDebug={runDebug} - setRunDebug={setRunDebug} - runLogLevel={runLogLevel} - setRunLogLevel={setRunLogLevel} - runLogLevelTouched={runLogLevelTouched} - setRunLogLevelTouched={setRunLogLevelTouched} - useFavicons={useFavicons} - faviconOnly={faviconOnly} - trackerIconSrcByName={trackerIconSrcByName} - /> - )} -
- { - if (!open) { - setLightboxImage(""); - setLightboxAlt(""); - } - }} - > - - - - {lightboxAlt || "Preview"} -
- -
- {lightboxAlt -
-
-
- { - if (!open) closeHostBrowser(); - }} - > - - - -
-
- -

Host browser

-
- -

- {hostBrowser?.currentPath || "Computer"} -

-
-
- - - -
-
- - - {hostBrowserMode === "folder" && hostBrowser?.currentPath ? ( - - ) : null} - -
- {hostBrowserError ?

{hostBrowserError}

: null} - {hostBrowserLoading ?

Loading host paths...

: null} - {!hostBrowserLoading && hostBrowser ? ( -
- {hostBrowserEntries.length === 0 ? ( -

No matching paths.

- ) : ( - hostBrowserEntries.map((entry, index) => ( -
{ - hostBrowserEntryRefs.current[index] = element; - }} - tabIndex={0} - onKeyDown={(event) => handleHostBrowserEntryKeyDown(event, entry, index)} - onDoubleClick={() => { - if (entry.isDir) { - void browseHostDirectory(entry.path); - return; - } - void selectHostPath(entry.path, entry.isDir); - }} - > - - {entry.isDir ? "[DIR] " : ""} - {entry.name} - - - {entry.isDir - ? "Folder" - : `${Math.round(entry.size / 1024).toLocaleString()} KiB`} - - - {entry.isDir ? ( - - ) : null} - {(hostBrowserMode === "folder" && entry.isDir) || - (hostBrowserMode === "file" && !entry.isDir) ? ( - - ) : null} - -
- )) - )} -
- ) : null} -
-
-
-
-
- ); -} diff --git a/gui/frontend/src/components/LogSettingsPanel.test.tsx b/gui/frontend/src/components/LogSettingsPanel.test.tsx deleted file mode 100644 index 2243cff7..00000000 --- a/gui/frontend/src/components/LogSettingsPanel.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { screen, waitFor } from "@testing-library/dom"; -import { cleanup, render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import LogSettingsPanel from "./LogSettingsPanel"; -import { EventsOn } from "../utils/runtime"; - -vi.mock("../utils/runtime", () => ({ - EventsOn: vi.fn(() => vi.fn()), -})); - -const installGoAPI = () => { - (globalThis as any).go = { - guiapp: { - App: { - GetLogPath: vi.fn().mockResolvedValue("C:/logs/upbrr.log"), - GetRecentLogs: vi.fn().mockResolvedValue([]), - GetLogExclusions: vi.fn().mockResolvedValue([]), - StartLogStream: vi.fn().mockResolvedValue("stream-1"), - StopLogStream: vi.fn().mockResolvedValue(undefined), - UpdateLogExclusions: vi.fn().mockResolvedValue(undefined), - }, - }, - }; -}; - -describe("LogSettingsPanel", () => { - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - delete (globalThis as any).go; - }); - - it("renders log path and updates logging level", async () => { - installGoAPI(); - const updateConfigValue = vi.fn(); - - render( -
{label}
} - updateConfigValue={updateConfigValue} - />, - ); - - await waitFor(() => expect(screen.getByText("C:/logs/upbrr.log")).toBeInTheDocument()); - - await userEvent.selectOptions(screen.getByLabelText("Verbosity"), "debug"); - - expect(updateConfigValue).toHaveBeenCalledWith(["Logging", "Level"], "debug"); - }); - - it("keeps distinct rows when a restarted log stream reuses IDs", async () => { - installGoAPI(); - const app = (globalThis as any).go.guiapp.App; - app.GetRecentLogs.mockResolvedValueOnce([ - { - ID: 1, - Time: "2026-06-15T00:00:00.000Z", - Level: "info", - Message: "before restart", - }, - ]); - app.GetRecentLogs.mockResolvedValueOnce([ - { - ID: 1, - Time: "2026-06-15T00:01:00.000Z", - Level: "info", - Message: "after restart", - }, - ]); - - render( -
{label}
} - updateConfigValue={vi.fn()} - />, - ); - - await waitFor(() => expect(screen.getByText("before restart")).toBeInTheDocument()); - await waitFor(() => expect(screen.getByText("after restart")).toBeInTheDocument()); - }); - - it("dedupes exact recent/live overlap rows", async () => { - installGoAPI(); - const app = (globalThis as any).go.guiapp.App; - const row = { - ID: 2, - Time: "2026-06-15T00:00:00.000Z", - Level: "info", - Message: "overlap", - }; - app.GetRecentLogs.mockResolvedValue([row]); - vi.mocked(EventsOn).mockImplementationOnce((_eventName, callback) => { - callback(row); - return vi.fn(); - }); - - render( -
{label}
} - updateConfigValue={vi.fn()} - />, - ); - - await waitFor(() => expect(EventsOn).toHaveBeenCalled()); - await waitFor(() => expect(screen.getAllByText("overlap")).toHaveLength(1)); - }); - - it("does not restart the log stream when auto-scroll changes", async () => { - installGoAPI(); - const app = (globalThis as any).go.guiapp.App; - - render( -
{label}
} - updateConfigValue={vi.fn()} - />, - ); - - await waitFor(() => expect(app.StartLogStream).toHaveBeenCalledTimes(1)); - - await userEvent.click(screen.getByLabelText("Auto-scroll logs")); - - expect(app.StopLogStream).not.toHaveBeenCalled(); - expect(app.StartLogStream).toHaveBeenCalledTimes(1); - }); - - it("stops a started stream when event subscription fails", async () => { - installGoAPI(); - const app = (globalThis as any).go.guiapp.App; - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.mocked(EventsOn).mockImplementationOnce(() => { - throw new Error("subscribe failed"); - }); - - render( -
{label}
} - updateConfigValue={vi.fn()} - />, - ); - - try { - await waitFor(() => expect(app.StopLogStream).toHaveBeenCalledWith("stream-1")); - expect(consoleError).toHaveBeenCalledWith("Failed to start log stream", expect.any(Error)); - } finally { - consoleError.mockRestore(); - } - }); -}); diff --git a/gui/frontend/src/components/RenderedDescription.test.tsx b/gui/frontend/src/components/RenderedDescription.test.tsx deleted file mode 100644 index 44fc8b24..00000000 --- a/gui/frontend/src/components/RenderedDescription.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import RenderedDescription from "./RenderedDescription"; - -describe("RenderedDescription external links", () => { - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - delete (globalThis as typeof globalThis & { go?: unknown }).go; - }); - - it("marks rendered links to open in a new tab", () => { - render(); - - const link = screen.getByRole("link", { name: "View image" }); - - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", "noreferrer"); - }); - - it("opens web UI links in a new tab", () => { - const open = vi.spyOn(window, "open").mockImplementation(() => null); - render(); - - fireEvent.click(screen.getByRole("link", { name: "Torrent" })); - - expect(open).toHaveBeenCalledWith( - "https://example.com/torrent", - "_blank", - "noopener,noreferrer", - ); - }); - - it("opens GUI links through the default-browser bridge", async () => { - const openExternalURL = vi.fn().mockResolvedValue(undefined); - (globalThis as any).go = { - guiapp: { App: { OpenExternalURL: openExternalURL } }, - }; - const open = vi.spyOn(window, "open").mockImplementation(() => null); - render(); - - fireEvent.click(screen.getByRole("link", { name: "Release" })); - - await waitFor(() => { - expect(openExternalURL).toHaveBeenCalledWith("https://example.com/release"); - }); - expect(open).not.toHaveBeenCalled(); - }); - - it("routes aux-clicks through the same external-link handling", async () => { - const openExternalURL = vi.fn().mockResolvedValue(undefined); - (globalThis as any).go = { - guiapp: { App: { OpenExternalURL: openExternalURL } }, - }; - render(); - - fireEvent( - screen.getByRole("link", { name: "Raw URL" }), - new MouseEvent("auxclick", { bubbles: true, button: 1 }), - ); - - await waitFor(() => { - expect(openExternalURL).toHaveBeenCalledWith("https://example.com/raw"); - }); - }); - - it("blocks relative links instead of opening them against the app origin", () => { - const openExternalURL = vi.fn().mockResolvedValue(undefined); - (globalThis as any).go = { - guiapp: { App: { OpenExternalURL: openExternalURL } }, - }; - const open = vi.spyOn(window, "open").mockImplementation(() => null); - render(); - - const event = new MouseEvent("click", { bubbles: true, cancelable: true }); - const dispatched = screen.getByRole("link", { name: "Relative release" }).dispatchEvent(event); - - expect(dispatched).toBe(false); - expect(event.defaultPrevented).toBe(true); - expect(openExternalURL).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); - }); -}); diff --git a/gui/frontend/src/hooks/useScreenshots.ts b/gui/frontend/src/hooks/useScreenshots.ts deleted file mode 100644 index 757fe781..00000000 --- a/gui/frontend/src/hooks/useScreenshots.ts +++ /dev/null @@ -1,811 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { useState, useRef, useCallback, useMemo } from "react"; -import type { - ScreenshotImage, - ScreenshotPlan, - ScreenshotSelection, - ScreenshotPreviewImage, - ScreenshotResult, - ScreenshotLinkedImage, - ExternalIDOverrides, - ReleaseNameOverrides, -} from "../types"; -import { normalizeOverrides, normalizeReleaseOverrides } from "../utils"; - -interface ScreenshotHookProps { - path: string; - idOverrideState?: { overrides?: ExternalIDOverrides }; - releaseOverrideState?: { overrides?: ReleaseNameOverrides }; -} - -export const useScreenshots = ({ - path, - idOverrideState, - releaseOverrideState, -}: ScreenshotHookProps) => { - // State: Plan & suggestions - const [screenshotPlan, setScreenshotPlan] = useState(null); - const [screenshotSelections, setScreenshotSelections] = useState([]); - - // State: UI controls - const [screenshotsLoading, setScreenshotsLoading] = useState(false); - const [screenshotsError, setScreenshotsError] = useState(""); - const [screenshotsSettingsSaving, setScreenshotsSettingsSaving] = useState(false); - const [showFrameSelections, setShowFrameSelections] = useState(false); - - // State: Live preview - const [livePreviewSeconds, setLivePreviewSeconds] = useState(0); - const [livePreviewImage, setLivePreviewImage] = useState(""); - const [livePreviewLoading, setLivePreviewLoading] = useState(false); - const [livePreviewError, setLivePreviewError] = useState(""); - const livePreviewRequestId = useRef(0); - - // State: Image collections - const [previewLoadingIndex, setPreviewLoadingIndex] = useState(null); - const [previewImages, setPreviewImages] = useState([]); - const [existingImages, setExistingImages] = useState([]); - const [existingTrackerImages, setExistingTrackerImages] = useState([]); - const [finalImages, setFinalImages] = useState([]); - const [finalResult, setFinalResult] = useState(null); - - // Deletion tracking for UI updates - const [deletedTrackerImages, setDeletedTrackerImages] = useState([]); - - // Refs for state sync - const finalImagesRef = useRef([]); - - // Memoized tracker image maps - const trackerImageURLs = useMemo(() => { - const urls = new Set(); - (screenshotPlan?.TrackerImageLinks || []).forEach((link) => { - if (link.URL && !deletedTrackerImages.includes(link.URL)) { - urls.add(link.URL); - } - }); - return Array.from(urls); - }, [screenshotPlan?.TrackerImageLinks, deletedTrackerImages]); - - const trackerLinkByPath = useMemo(() => { - const map = new Map(); - (screenshotPlan?.TrackerImageLinks || []).forEach((link) => { - if (link.Path) { - map.set(link.Path, link); - } - }); - return map; - }, [screenshotPlan?.TrackerImageLinks]); - - const trackerLinkByURL = useMemo(() => { - const map = new Map(); - (screenshotPlan?.TrackerImageLinks || []).forEach((link) => { - if (link.URL) { - map.set(link.URL, link); - } - }); - return map; - }, [screenshotPlan?.TrackerImageLinks]); - - // Helper: normalize image path (URI decoding) - const normalizeImagePath = useCallback((value: string) => { - if (!value) return ""; - if (value.startsWith("file://")) { - const trimmed = value.replaceAll("file://", ""); - try { - return decodeURIComponent(trimmed); - } catch { - return trimmed; - } - } - return value; - }, []); - - // Helper: read screenshot image from backend - const readScreenshotImage = useCallback( - async (image: ScreenshotImage): Promise => { - const reader = globalThis.go?.guiapp?.App?.ReadScreenshotImage; - if (!reader) { - throw new Error("Screenshot preview is unavailable in this build."); - } - const dataUri = await reader(image.Path); - return { image, dataUri } as ScreenshotPreviewImage; - }, - [], - ); - - // Load screenshot plan from backend - const loadScreenshotPlan = useCallback( - async (revealSelections = false): Promise => { - setScreenshotsError(""); - const fetcher = globalThis.go?.guiapp?.App?.FetchScreenshotPlan; - if (!fetcher) { - setScreenshotsError("Screenshot planning is unavailable in this build."); - return null; - } - if (!path.trim()) { - setScreenshotsError("Please select a file or folder."); - return null; - } - - setScreenshotsLoading(true); - try { - const result = await fetcher( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - ); - setScreenshotPlan(result); - setScreenshotSelections(result.SuggestedSelections || []); - setLivePreviewImage(""); - setLivePreviewError(""); - - if (revealSelections) { - setShowFrameSelections(true); - } - - if (!livePreviewSeconds && (result.SuggestedSelections || []).length > 0) { - const initial = result.SuggestedSelections[0]?.TimestampSeconds || 0; - setLivePreviewSeconds(initial); - } - - setExistingImages([]); - setExistingTrackerImages([]); - setPreviewImages([]); - setFinalResult(null); - - // Load existing screenshot previews - if (result.ExistingScreenshots && result.ExistingScreenshots.length > 0) { - const previews = await Promise.all( - result.ExistingScreenshots.map(async (image) => { - try { - return await readScreenshotImage(image); - } catch { - return null; - } - }), - ); - setExistingImages( - previews.filter((entry): entry is ScreenshotPreviewImage => Boolean(entry)), - ); - } - - // Load existing tracker screenshot previews - if (result.ExistingTrackerScreenshots && result.ExistingTrackerScreenshots.length > 0) { - const previews = await Promise.all( - result.ExistingTrackerScreenshots.map(async (image) => { - try { - return await readScreenshotImage(image); - } catch { - return null; - } - }), - ); - setExistingTrackerImages( - previews.filter((entry): entry is ScreenshotPreviewImage => Boolean(entry)), - ); - } - - // Load final selections - if ( - result.FinalSelections && - result.FinalSelections.length > 0 && - finalImagesRef.current.length === 0 - ) { - const previews = await Promise.all( - result.FinalSelections.map(async (image) => { - try { - return await readScreenshotImage(image); - } catch { - return null; - } - }), - ); - setFinalImages( - previews.filter((entry): entry is ScreenshotPreviewImage => Boolean(entry)), - ); - } - - return result; - } catch (err) { - const message = String(err); - if (message.includes("screenshot plan requires metadata preview")) { - setScreenshotsError( - "Fetch metadata first to cache a preview before planning screenshots.", - ); - } else { - setScreenshotsError(message); - } - return null; - } finally { - setScreenshotsLoading(false); - } - }, - [path, idOverrideState, releaseOverrideState, livePreviewSeconds, readScreenshotImage], - ); - - // Save final selections - const saveFinalSelections = useCallback( - async (next: ScreenshotPreviewImage[]) => { - setFinalImages(next); - const saver = globalThis.go?.guiapp?.App?.SaveFinalScreenshotSelections; - if (!saver || !path.trim()) { - return; - } - try { - await saver( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - next.map((entry) => entry.image), - ); - } catch (err) { - setScreenshotsError(String(err)); - } - }, - [path, idOverrideState, releaseOverrideState], - ); - - // Update selection timestamp - const updateSelectionTime = useCallback((index: number, value: string) => { - const next = Number.parseFloat(value); - setScreenshotSelections((prev) => { - return prev.map((selection) => - selection.Index === index - ? { - ...selection, - TimestampSeconds: Number.isFinite(next) ? next : 0, - Source: "manual", - } - : selection, - ); - }); - }, []); - - // Update selection frame - const updateSelectionFrame = useCallback((index: number, value: string) => { - const next = Number.parseInt(value, 10); - setScreenshotSelections((prev) => { - return prev.map((selection) => - selection.Index === index - ? { - ...selection, - Frame: Number.isFinite(next) ? next : 0, - Source: "manual", - } - : selection, - ); - }); - }, []); - - // Check if final image is already selected - const isFinalImageSelected = useCallback((pathValue: string) => { - if (!pathValue) return false; - return finalImagesRef.current.some((entry) => entry.image.Path === pathValue); - }, []); - - const updateFinalSelections = useCallback( - async (next: ScreenshotPreviewImage[], persist: boolean) => { - finalImagesRef.current = next; - setFinalImages(next); - if (persist) { - await saveFinalSelections(next); - } - }, - [saveFinalSelections], - ); - - // Add image to final selections - const addFinalSelection = useCallback( - (image: ScreenshotPreviewImage) => { - if (!image.image.Path) return; - const updated = mergeFinalSelections(finalImagesRef.current, [image]); - void updateFinalSelections(updated, true); - }, - [updateFinalSelections], - ); - - const removeFinalSelectionInternal = useCallback( - (pathValue: string, persist: boolean) => { - if (!pathValue) return false; - if (!finalImagesRef.current.some((entry) => entry.image.Path === pathValue)) { - return false; - } - const updated = finalImagesRef.current.filter((entry) => entry.image.Path !== pathValue); - void updateFinalSelections(updated, persist); - return true; - }, - [updateFinalSelections], - ); - - // Remove image from final selections - const removeFinalSelection = useCallback( - (pathValue: string) => { - removeFinalSelectionInternal(pathValue, true); - }, - [removeFinalSelectionInternal], - ); - - // Merge final selections intelligently - const mergeFinalSelections = ( - current: ScreenshotPreviewImage[], - additions: ScreenshotPreviewImage[], - ): ScreenshotPreviewImage[] => { - if (additions.length === 0) return current; - const seen = new Map(); - const merged = [...current]; - merged.forEach((item, index) => { - if (item.image.Path) { - seen.set(item.image.Path, index); - } - }); - - additions.forEach((item) => { - const pathValue = item.image.Path; - if (!pathValue) return; - const existingIndex = seen.get(pathValue); - if (existingIndex === undefined) { - const ts = item.image.TimestampSeconds || 0; - if (ts > 0) { - const insertAt = merged.findIndex((entry) => { - const entryTs = entry.image.TimestampSeconds || 0; - return entryTs > 0 && entryTs > ts; - }); - if (insertAt >= 0) { - merged.splice(insertAt, 0, item); - seen.clear(); - merged.forEach((entry, idx) => { - if (entry.image.Path) { - seen.set(entry.image.Path, idx); - } - }); - } else { - merged.push(item); - seen.set(pathValue, merged.length - 1); - } - } else { - merged.push(item); - seen.set(pathValue, merged.length - 1); - } - } - }); - - return merged; - }; - - // Reorder final selections via drag - const reorderFinalSelections = (dragIndex: number, targetIndex: number) => { - if (dragIndex === targetIndex) return; - const updated = [...finalImagesRef.current]; - const [dragged] = updated.splice(dragIndex, 1); - updated.splice(targetIndex, 0, dragged); - finalImagesRef.current = updated; - saveFinalSelections(updated); - }; - - // Delete a set of images - const deleteImageSet = useCallback( - async (images: ScreenshotImage[], label: string): Promise => { - if (images.length === 0) return []; - const deleter = globalThis.go?.guiapp?.App?.DeleteScreenshot; - if (!deleter || !path.trim()) { - return [] as ScreenshotImage[]; - } - if (!globalThis.confirm(`Delete all ${label} images from the temp folder?`)) { - return [] as ScreenshotImage[]; - } - - const deleted: ScreenshotImage[] = []; - const failures: string[] = []; - for (const image of images) { - try { - await deleter( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - image.Path, - ); - deleted.push(image); - } catch (err) { - failures.push(String(err)); - } - } - - if (failures.length > 0) { - setScreenshotsError(failures[0]); - } - return deleted; - }, - [path, idOverrideState, releaseOverrideState], - ); - - const deleteTrackerImageURL = useCallback( - async (url: string) => { - const deleter = (globalThis as any)?.go?.guiapp?.App?.DeleteTrackerImageURL; - if (!deleter || !path.trim() || !url) { - return; - } - try { - await deleter( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - url, - ); - } catch (err) { - setScreenshotsError(String(err)); - } - }, - [path, idOverrideState, releaseOverrideState], - ); - - const deleteTrackerImageFile = useCallback( - async (imagePath: string) => { - const deleter = globalThis.go?.guiapp?.App?.DeleteScreenshot; - if (!deleter || !path.trim() || !imagePath) { - return; - } - try { - await deleter( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - imagePath, - ); - } catch (err) { - setScreenshotsError(String(err)); - } - }, - [path, idOverrideState, releaseOverrideState], - ); - - const removeTrackerImageURLState = useCallback((url: string) => { - if (!url) return; - setDeletedTrackerImages((prev) => { - if (prev.includes(url)) { - return prev; - } - return [...prev, url]; - }); - setScreenshotPlan((prev) => { - if (!prev) return prev; - const trackerLinks = prev.TrackerImageLinks || []; - return { - ...prev, - TrackerImageLinks: trackerLinks.filter((entry) => entry.URL !== url), - }; - }); - }, []); - - // Delete all existing images - const handleDeleteAllExistingImages = useCallback(async () => { - const images = screenshotPlan?.ExistingScreenshots?.length - ? screenshotPlan.ExistingScreenshots - : existingImages.map((entry) => entry.image); - const deleted = await deleteImageSet(images, "existing"); - if (deleted.length === 0) return; - const deletedPaths = new Set(deleted.map((image) => image.Path)); - setExistingImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - if (finalImagesRef.current.length > 0) { - await saveFinalSelections( - finalImagesRef.current.filter((entry) => !deletedPaths.has(entry.image.Path)), - ); - } - setScreenshotPlan((prev) => { - if (!prev) return prev; - const existing = prev.ExistingScreenshots || []; - return { - ...prev, - ExistingScreenshots: existing.filter((entry) => !deletedPaths.has(entry.Path)), - }; - }); - }, [screenshotPlan, existingImages, deleteImageSet, saveFinalSelections]); - - // Delete all tracker images - const handleDeleteAllTrackerImages = useCallback(async () => { - const images = screenshotPlan?.ExistingTrackerScreenshots?.length - ? screenshotPlan.ExistingTrackerScreenshots - : existingTrackerImages.map((entry) => entry.image); - const deleted = await deleteImageSet(images, "tracker temp"); - if (deleted.length === 0) return; - const deletedPaths = new Set(deleted.map((image) => image.Path)); - const linkedURLs = deleted - .map((image) => trackerLinkByPath.get(image.Path)) - .filter((link): link is ScreenshotLinkedImage => Boolean(link)); - if (linkedURLs.length > 0) { - setDeletedTrackerImages((prev) => { - const next = new Set(prev); - linkedURLs.forEach((link) => { - if (link.URL) { - next.add(link.URL); - } - }); - return Array.from(next); - }); - for (const link of linkedURLs) { - if (!link.URL) { - continue; - } - await deleteTrackerImageURL(link.URL); - } - } - - setExistingTrackerImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - if (finalImagesRef.current.length > 0) { - await saveFinalSelections( - finalImagesRef.current.filter((entry) => !deletedPaths.has(entry.image.Path)), - ); - } - setScreenshotPlan((prev) => { - if (!prev) return prev; - const trackerExisting = prev.ExistingTrackerScreenshots || []; - const trackerLinks = prev.TrackerImageLinks || []; - return { - ...prev, - ExistingTrackerScreenshots: trackerExisting.filter( - (entry) => !deletedPaths.has(entry.Path), - ), - TrackerImageLinks: trackerLinks.filter((entry) => !deletedPaths.has(entry.Path)), - }; - }); - }, [ - screenshotPlan, - existingTrackerImages, - trackerLinkByPath, - deleteImageSet, - saveFinalSelections, - deleteTrackerImageURL, - ]); - - // Delete all preview images - const handleDeleteAllPreviewImages = useCallback(async () => { - const images = previewImages.map((entry) => entry.image); - const deleted = await deleteImageSet(images, "preview"); - if (deleted.length === 0) return; - const deletedPaths = new Set(deleted.map((image) => image.Path)); - setPreviewImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - }, [previewImages, deleteImageSet]); - - // Delete all final images - const handleDeleteAllFinalImages = useCallback(async () => { - const images = finalImages.map((entry) => entry.image); - const deleted = await deleteImageSet(images, "final"); - if (deleted.length === 0) return; - const deletedPaths = new Set(deleted.map((image) => image.Path)); - const linkedURLs = deleted - .map((image) => trackerLinkByPath.get(image.Path)) - .filter((link): link is ScreenshotLinkedImage => Boolean(link)); - if (linkedURLs.length > 0) { - setDeletedTrackerImages((prev) => { - const next = new Set(prev); - linkedURLs.forEach((link) => { - if (link.URL) { - next.add(link.URL); - } - }); - return Array.from(next); - }); - for (const link of linkedURLs) { - if (!link.URL) { - continue; - } - await deleteTrackerImageURL(link.URL); - } - } - await saveFinalSelections( - finalImagesRef.current.filter((entry) => !deletedPaths.has(entry.image.Path)), - ); - setExistingImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - setExistingTrackerImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - setPreviewImages((prev) => prev.filter((entry) => !deletedPaths.has(entry.image.Path))); - setScreenshotPlan((prev) => { - if (!prev) return prev; - const existing = prev.ExistingScreenshots || []; - const trackerExisting = prev.ExistingTrackerScreenshots || []; - const trackerLinks = prev.TrackerImageLinks || []; - return { - ...prev, - ExistingScreenshots: existing.filter((entry) => !deletedPaths.has(entry.Path)), - ExistingTrackerScreenshots: trackerExisting.filter( - (entry) => !deletedPaths.has(entry.Path), - ), - TrackerImageLinks: trackerLinks.filter((entry) => !deletedPaths.has(entry.Path)), - }; - }); - }, [finalImages, deleteImageSet, saveFinalSelections, trackerLinkByPath, deleteTrackerImageURL]); - - const handleDeleteTrackerImageURL = useCallback( - async (url: string, persistFinal = true): Promise => { - if (!url) return false; - const linked = trackerLinkByURL.get(url); - let removedFinal = false; - if (linked?.Path) { - await deleteTrackerImageFile(linked.Path); - } - await deleteTrackerImageURL(url); - removeTrackerImageURLState(url); - if (linked?.Path) { - removedFinal = removeFinalSelectionInternal(linked.Path, persistFinal); - } - return removedFinal; - }, - [ - deleteTrackerImageURL, - removeTrackerImageURLState, - trackerLinkByURL, - removeFinalSelectionInternal, - deleteTrackerImageFile, - ], - ); - - // Delete a specific tracker image - const handleDeleteTrackerImage = useCallback( - async (url: string) => { - setScreenshotsError(""); - if (!url) { - setScreenshotsError("Tracker image URL is missing."); - return; - } - if (!globalThis.confirm("Remove this tracker image from the list?")) { - return; - } - await handleDeleteTrackerImageURL(url, true); - }, - [handleDeleteTrackerImageURL], - ); - - // Delete all tracker image URLs - const handleDeleteAllTrackerImageURLs = useCallback(async () => { - const urls = trackerImageURLs; - if (urls.length === 0) return; - if (!globalThis.confirm("Remove all tracker images from the list?")) { - return; - } - let removedFinal = false; - for (const url of urls) { - const removed = await handleDeleteTrackerImageURL(url, false); - if (removed) { - removedFinal = true; - } - } - if (removedFinal) { - await saveFinalSelections(finalImagesRef.current); - } - }, [trackerImageURLs, handleDeleteTrackerImageURL, saveFinalSelections]); - - // Reset all screenshot state - const resetScreenshotState = useCallback(() => { - setScreenshotPlan(null); - setScreenshotSelections([]); - setScreenshotsLoading(false); - setScreenshotsError(""); - setShowFrameSelections(false); - setPreviewLoadingIndex(null); - setPreviewImages([]); - setExistingImages([]); - setExistingTrackerImages([]); - setFinalImages([]); - setFinalResult(null); - setLivePreviewSeconds(0); - setLivePreviewImage(""); - setLivePreviewLoading(false); - setLivePreviewError(""); - setDeletedTrackerImages([]); - finalImagesRef.current = []; - }, []); - - // Sync finalImagesRef with finalImages state - useMemo(() => { - finalImagesRef.current = finalImages; - }, [finalImages]); - - // Compute tracker image links from screenshot plan - const trackerImageLinks = useMemo(() => { - return screenshotPlan?.TrackerImageLinks || []; - }, [screenshotPlan?.TrackerImageLinks]); - - // Compute merged upload candidates from all image sources - const uploadCandidates = useMemo(() => { - const merged = new Map(); - - const addImages = (items: ScreenshotPreviewImage[]) => { - items.forEach((item) => { - const pathValue = item.image.Path; - if (!pathValue) return; - const existing = merged.get(pathValue); - if (!existing || (item.image.Host && item.image.RawURL && !existing.image.Host)) { - // Prefer entries with upload metadata - merged.set(pathValue, item); - } - }); - }; - - addImages(finalImages); - addImages(previewImages); - addImages(existingTrackerImages); - addImages(existingImages); - - return Array.from(merged.values()); - }, [finalImages, previewImages, existingTrackerImages, existingImages]); - - // Compute set of upload candidate paths - const uploadCandidatePaths = useMemo(() => { - const paths = new Set(); - uploadCandidates.forEach((item) => { - if (item.image.Path) { - paths.add(item.image.Path); - } - }); - return paths; - }, [uploadCandidates]); - - return { - // State - screenshotPlan, - screenshotSelections, - screenshotsLoading, - screenshotsError, - screenshotsSettingsSaving, - showFrameSelections, - livePreviewSeconds, - livePreviewImage, - livePreviewLoading, - livePreviewError, - livePreviewRequestId, - previewLoadingIndex, - previewImages, - existingImages, - existingTrackerImages, - finalImages, - finalResult, - deletedTrackerImages, - trackerImageURLs, - trackerLinkByPath, - trackerLinkByURL, - trackerImageLinks, - uploadCandidates, - uploadCandidatePaths, - - // Refs - finalImagesRef, - - // Setters - setScreenshotPlan, - setScreenshotSelections, - setScreenshotsLoading, - setScreenshotsError, - setScreenshotsSettingsSaving, - setShowFrameSelections, - setLivePreviewSeconds, - setLivePreviewImage, - setLivePreviewLoading, - setLivePreviewError, - setPreviewLoadingIndex, - setPreviewImages, - setExistingImages, - setExistingTrackerImages, - setFinalImages, - setFinalResult, - setDeletedTrackerImages, - - // Handlers & utilities - normalizeImagePath, - readScreenshotImage, - loadScreenshotPlan, - saveFinalSelections, - updateSelectionTime, - updateSelectionFrame, - isFinalImageSelected, - addFinalSelection, - removeFinalSelection, - reorderFinalSelections, - handleDeleteAllExistingImages, - handleDeleteAllTrackerImages, - handleDeleteAllPreviewImages, - handleDeleteAllFinalImages, - handleDeleteTrackerImage, - handleDeleteTrackerImageURL, - handleDeleteAllTrackerImageURLs, - resetScreenshotState, - }; -}; diff --git a/gui/frontend/src/hooks/useSettingsState.test.ts b/gui/frontend/src/hooks/useSettingsState.test.ts deleted file mode 100644 index e479ae0c..00000000 --- a/gui/frontend/src/hooks/useSettingsState.test.ts +++ /dev/null @@ -1,1183 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { createElement } from "react"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - nextQbitDirectState, - normalizeTorrentClientForSave, - normalizeTorrentClientsForSave, - useSettingsState, -} from "./useSettingsState"; - -afterEach(() => { - cleanup(); - latestPayload = ""; - delete (globalThis as typeof globalThis & { go?: any }).go; -}); - -describe("normalizeTorrentClientForSave", () => { - it("preserves watch client fields", () => { - expect( - normalizeTorrentClientForSave({ - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }), - ).toEqual({ - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }); - }); - - it("migrates legacy qbit fields and removes aliases", () => { - expect( - normalizeTorrentClientForSave({ - TorrentClient: "qbit", - URL: "http://localhost:8080", - Username: "user", - Password: "secret", - Category: "movies", - Tags: ["AITHER", "BLU"], - }), - ).toEqual({ - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - QbitCategoryValue: "movies", - QbitTag: "AITHER,BLU", - }); - }); - - it("maps legacy TLS skip verify to certificate verification before removing aliases", () => { - expect( - normalizeTorrentClientForSave({ - TorrentClient: "qbit", - TLSSkipVerify: true, - }), - ).toEqual({ - VerifyWebUICertificate: false, - }); - }); -}); - -describe("normalizeTorrentClientsForSave", () => { - it("normalizes each configured client without dropping watch-folder config", () => { - expect( - normalizeTorrentClientsForSave({ - TorrentClients: { - qbit: { - Type: "qbit", - URL: "http://localhost:8080", - Username: "user", - Password: "secret", - }, - watch: { - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }, - }, - }), - ).toEqual({ - TorrentClients: { - qbit: { - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - }, - watch: { - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }, - }, - }); - }); -}); - -describe("nextQbitDirectState", () => { - it("clears proxy and direct credentials when qbit direct is disabled", () => { - expect( - nextQbitDirectState( - { - QuiProxyURL: "http://proxy.local", - QbitURL: "http://localhost:8080", - QbitPort: 8080, - QbitUser: "user", - QbitPass: "secret", - URL: "http://legacy.local", - Username: "legacy-user", - Password: "legacy-pass", - }, - false, - ), - ).toEqual({ - QuiProxyURL: "", - QbitURL: "", - QbitPort: 0, - QbitUser: "", - QbitPass: "", - URL: "", - Username: "", - Password: "", - }); - }); -}); - -function TorrentClientsHarness() { - const state = useSettingsState({ activeTab: "settings" }); - - return createElement( - "div", - null, - state.renderTorrentClientsSection(false), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -function ClientSetupHarness() { - const state = useSettingsState({ activeTab: "settings" }); - const clientSetup = state.configData?.ClientSetup; - - if (!clientSetup || typeof clientSetup !== "object" || Array.isArray(clientSetup)) { - return createElement("div", null); - } - - const meta = state.sectionFieldMeta.ClientSetup ?? {}; - - return createElement( - "div", - null, - ...Object.entries(clientSetup).map(([key, value]) => - state.renderField(key, value, ["ClientSetup", key], meta[key]), - ), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -function TrackerSettingsHarness() { - const state = useSettingsState({ activeTab: "settings" }); - - return createElement( - "div", - null, - state.renderTrackerSection(false), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -function TrackerSettingsAdvancedHarness() { - const state = useSettingsState({ activeTab: "settings" }); - - return createElement( - "div", - null, - state.renderTrackerSection(true), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -function ImageHostingHarness() { - const state = useSettingsState({ activeTab: "settings" }); - - return createElement( - "div", - null, - state.renderImageHostingSection(), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -function ScreenshotSettingsHarness() { - const state = useSettingsState({ activeTab: "settings" }); - const config = state.screenshotConfig; - if (!config) { - return createElement("div"); - } - return createElement( - "div", - null, - state.renderField( - "MaxMenuItems", - config.MaxMenuItems, - ["ScreenshotHandling", "MaxMenuItems"], - state.sectionFieldMeta.ScreenshotHandling?.MaxMenuItems, - ), - createElement(PayloadCapture, { value: state.buildSavePayload() }), - ); -} - -let latestPayload = ""; - -/** Captures save payloads without rendering secret-shaped values into DOM snapshots. */ -function PayloadCapture({ value }: { value: string | null }) { - latestPayload = value ?? ""; - return null; -} - -/** Parses the latest captured payload for focused assertions outside matcher output. */ -function readPayload() { - return JSON.parse(latestPayload || "{}") as T; -} - -function AdvancedFieldMetaHarness() { - const state = useSettingsState({ activeTab: "settings" }); - const advancedBySection = Object.fromEntries( - Object.entries(state.sectionFieldMeta).map(([section, fields]) => [ - section, - Object.values(fields) - .filter((field) => field.advanced) - .map((field) => field.key) - .sort(), - ]), - ); - - return createElement( - "pre", - { "data-testid": "advanced-fields" }, - JSON.stringify(advancedBySection), - ); -} - -describe("settings advanced fields", () => { - it("matches the configured per-section advanced allowlist", () => { - render(createElement(AdvancedFieldMetaHarness)); - - const advancedBySection = JSON.parse( - screen.getByTestId("advanced-fields").textContent ?? "{}", - ) as Record; - - expect(advancedBySection.MainSettings).toEqual([]); - expect(advancedBySection.Metadata).toEqual([ - "BTNAPI", - "BlurayScore", - "BluraySingleScore", - "CheckPredb", - "SkipAutoTorrent", - "SkipTrackerFilenameLookup", - "UserOverrides", - ]); - expect(advancedBySection.ScreenshotHandling).toEqual([ - "Desat", - "FFmpegCompression", - "FFmpegLimit", - "MaxConcurrentUploads", - "ProcessLimit", - "TonemapAlgorithm", - ]); - expect(advancedBySection.Description).toEqual([ - "CharLimit", - "CustomSignature", - "FileLimit", - "LogoLanguage", - "LogoSize", - "ProcessLimit", - ]); - expect(advancedBySection.PostUpload).toEqual(["InjectDelay", "MaxConcurrentTrackers"]); - expect(advancedBySection.TorrentCreation).toEqual([]); - expect(advancedBySection.TorrentClients).toEqual(["VerifyWebUICertificate"]); - }); -}); - -describe("DVD menu screenshot settings", () => { - it("loads the default maximum and preserves edits in the save payload", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => JSON.stringify({ ScreenshotHandling: { MaxMenuItems: 6 } }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(ScreenshotSettingsHarness)); - const input = await screen.findByLabelText("Maximum DVD menu images"); - expect(input).toHaveValue(6); - fireEvent.change(input, { target: { value: "8" } }); - await waitFor(() => { - const payload = readPayload<{ ScreenshotHandling?: { MaxMenuItems?: number } }>(); - expect(payload.ScreenshotHandling?.MaxMenuItems).toBe(8); - }); - }); -}); - -describe("renderTorrentClientsSection", () => { - it("renders watch client fields and preserves qbit clients on update", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - TorrentClients: { - watcher: { - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }, - qbit: { - Type: "qbit", - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - AutomaticManagementPaths: ["/media"], - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TorrentClientsHarness)); - - await waitFor(() => expect(screen.getByText("watcher")).toBeInTheDocument()); - - const watchCard = screen.getByText("watcher").closest(".settings-card"); - const qbitCard = screen.getByText("qbit").closest(".settings-card"); - expect(watchCard).toBeTruthy(); - expect(qbitCard).toBeTruthy(); - - const watchScope = within(watchCard as HTMLElement); - const qbitScope = within(qbitCard as HTMLElement); - - expect(watchScope.getByLabelText("Type")).toHaveValue("watch"); - expect(watchScope.getByLabelText("Watch folder")).toHaveValue("/watch"); - expect(watchScope.getByLabelText("Storage directory")).toHaveValue("/storage"); - expect(qbitScope.getByLabelText("qBit URL")).toHaveValue("http://localhost:8080"); - expect(qbitScope.getByLabelText("Automatic management paths 1")).toHaveValue("/media"); - expect(qbitScope.getByRole("button", { name: "Add Linked folder item" })).toBeInTheDocument(); - expect(qbitScope.getByRole("button", { name: "Add Local path item" })).toBeInTheDocument(); - expect(qbitScope.getByRole("button", { name: "Add Remote path item" })).toBeInTheDocument(); - expect( - qbitScope.getByRole("button", { name: "Add Automatic management paths item" }), - ).toBeInTheDocument(); - expect(qbitScope.getByLabelText("qBit direct")).toBeChecked(); - - fireEvent.change(watchScope.getByLabelText("Watch folder"), { - target: { value: "/watch/new" }, - }); - - await waitFor(() => - expect(watchScope.getByLabelText("Watch folder")).toHaveValue("/watch/new"), - ); - - const payload = readPayload<{ - TorrentClients?: Record>; - }>(); - expect(payload.TorrentClients?.watcher).toEqual({ - Type: "watch", - WatchFolder: "/watch/new", - StorageDir: "/storage", - }); - expect(payload.TorrentClients?.qbit).toMatchObject({ - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - AutomaticManagementPaths: ["/media"], - }); - }); -}); - -describe("ClientSetup client selectors", () => { - it("renders default client empty option without a none sentinel", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ClientSetup: { - DefaultClient: "", - }, - TorrentClients: { - qbit: { - Type: "qbit", - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(ClientSetupHarness)); - - await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("")); - - const defaultClientSelect = screen.getByLabelText("Default client") as HTMLSelectElement; - expect(Array.from(defaultClientSelect.options).map((option) => option.value)).toEqual([ - "", - "qbit", - ]); - expect(Array.from(defaultClientSelect.options).map((option) => option.textContent)).toEqual([ - "", - "qbit", - ]); - - fireEvent.change(defaultClientSelect, { target: { value: "qbit" } }); - await waitFor(() => expect(defaultClientSelect).toHaveValue("qbit")); - - fireEvent.change(defaultClientSelect, { target: { value: "" } }); - await waitFor(() => expect(defaultClientSelect).toHaveValue("")); - - const payload = readPayload<{ - ClientSetup?: { DefaultClient?: string }; - }>(); - expect(payload.ClientSetup?.DefaultClient).toBe(""); - }); - - it("renders default, injected, and searching clients as torrent client dropdowns", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ClientSetup: { - DefaultClient: "qbit", - InjectClients: ["qbit"], - SearchClients: ["watcher"], - }, - TorrentClients: { - qbit: { - Type: "qbit", - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - }, - watcher: { - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(ClientSetupHarness)); - - await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("qbit")); - - expect(screen.getByLabelText("Injected clients 1")).toHaveValue("qbit"); - expect(screen.getByLabelText("Searching clients 1")).toHaveValue("watcher"); - - fireEvent.change(screen.getByLabelText("Default client"), { - target: { value: "watcher" }, - }); - fireEvent.change(screen.getByLabelText("Injected clients 1"), { - target: { value: "watcher" }, - }); - - await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("watcher")); - - const payload = readPayload<{ - ClientSetup?: { - DefaultClient?: string; - InjectClients?: string[]; - SearchClients?: string[]; - }; - }>(); - expect(payload.ClientSetup?.DefaultClient).toBe("watcher"); - expect(payload.ClientSetup?.InjectClients).toEqual(["watcher"]); - expect(payload.ClientSetup?.SearchClients).toEqual(["watcher"]); - }); -}); - -describe("Tracker client selectors", () => { - it("renders CZT passkey field without preserving stale URL or API key", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - CZT: { - LinkDirName: "", - URL: "https://czteam.example", - APIKey: "service-token", - AnnounceURL: "https://czteam.me/announce.php?passkey=stale", - Passkey: "user-passkey", - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["CZT"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("CZT", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("CZT", { selector: ".settings-card__summary-name" })); - - expect(screen.queryByLabelText("URL")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("API key")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Passkey")).toHaveValue("[REDACTED]"); - - const payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.CZT).toMatchObject({ - Passkey: "user-passkey", - }); - expect(payload.Trackers?.Trackers?.CZT?.URL).toBeUndefined(); - expect(payload.Trackers?.Trackers?.CZT?.APIKey).toBeUndefined(); - expect(payload.Trackers?.Trackers?.CZT?.AnnounceURL).toBeUndefined(); - }); - - it("creates CZT entries with passkey defaults", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: {}, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["CZT"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0)); - - const trackerSelects = screen.getAllByRole("combobox"); - const trackerSelect = trackerSelects[trackerSelects.length - 1] as HTMLSelectElement; - fireEvent.change(trackerSelect, { target: { value: "CZT" } }); - fireEvent.click(screen.getByRole("button", { name: "Add entry" })); - - await waitFor(() => - expect( - screen.getByText("CZT", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - - const payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.CZT).toMatchObject({ - Passkey: "", - }); - expect(payload.Trackers?.Trackers?.CZT?.URL).toBeUndefined(); - expect(payload.Trackers?.Trackers?.CZT?.APIKey).toBeUndefined(); - }); - - it("renders tracker torrent client as a configured client dropdown", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - AITHER: { - LinkDirName: "", - APIKey: "", - ImageHost: "", - TorrentClient: "qbit", - Anon: false, - }, - }, - }, - TorrentClients: { - qbit: { - Type: "qbit", - QbitURL: "http://localhost:8080", - QbitUser: "user", - QbitPass: "secret", - }, - watcher: { - Type: "watch", - WatchFolder: "/watch", - StorageDir: "/storage", - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["AITHER"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsAdvancedHarness)); - - await waitFor(() => - expect( - screen.getByText("AITHER", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("AITHER", { selector: ".settings-card__summary-name" })); - - await waitFor(() => expect(screen.getByLabelText("Torrent client")).toHaveValue("qbit")); - - const torrentClientSelect = screen.getByLabelText("Torrent client") as HTMLSelectElement; - expect(Array.from(torrentClientSelect.options).map((option) => option.textContent)).toEqual([ - "", - "qbit", - "watcher", - ]); - - fireEvent.change(screen.getByLabelText("Torrent client"), { - target: { value: "watcher" }, - }); - - await waitFor(() => expect(screen.getByLabelText("Torrent client")).toHaveValue("watcher")); - - const payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.AITHER?.TorrentClient).toBe("watcher"); - }); - - it("does not treat catalog tracker entries or default tracker membership as enabled config", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: ["AITHER", "BLU", "BHD"], - PreferredTracker: "", - Trackers: { - AITHER: { - URL: "https://aither.cc", - APIKey: "", - Anon: false, - }, - BLU: { - URL: "https://blutopia.cc", - APIKey: "", - Anon: false, - }, - BHD: { - URL: "https://beyond-hd.me", - APIKey: "tracker-token", - Anon: false, - }, - }, - }, - }), - GetDefaultConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: ["AITHER", "BLU", "BHD"], - PreferredTracker: "", - Trackers: { - AITHER: { - URL: "https://aither.cc", - APIKey: "", - Anon: false, - }, - BLU: { - URL: "https://blutopia.cc", - APIKey: "", - Anon: false, - }, - BHD: { - URL: "https://beyond-hd.me", - APIKey: "", - Anon: false, - }, - }, - }, - }), - ListKnownTrackers: async () => ["AITHER", "BLU", "BHD"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("BHD", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - - expect(screen.queryByText("AITHER", { selector: ".settings-card__summary-name" })).toBeNull(); - expect(screen.queryByText("BLU", { selector: ".settings-card__summary-name" })).toBeNull(); - expect(screen.getByText("1/1")).toBeInTheDocument(); - }); - - it("masks encrypted envelopes on tracker URL fields and preserves them for saves", async () => { - const encryptedURL = "upbrr-enc:v1:encrypted-btn-url"; - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - BTN: { - APIKey: "tracker-token", - URL: encryptedURL, - Username: "", - Password: "", - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["BTN"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("BTN", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("BTN", { selector: ".settings-card__summary-name" })); - - expect(screen.getByLabelText("URL")).toHaveValue("[REDACTED]"); - - let payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.BTN?.URL === encryptedURL).toBe(true); - - fireEvent.change(screen.getByLabelText("URL"), { - target: { value: "https://btn.example" }, - }); - - await waitFor(() => expect(screen.getByLabelText("URL")).toHaveValue("https://btn.example")); - - payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.BTN?.URL).toBe("https://btn.example"); - }); - - it("renders BTN announce URL from tracker schema when stored config lacks the key", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - BTN: { - APIKey: "tracker-token", - URL: "https://backup.landof.tv", - Username: "", - Password: "", - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["BTN"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("BTN", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("BTN", { selector: ".settings-card__summary-name" })); - - expect(screen.getByLabelText("Announce URL")).toHaveValue(""); - }); - - it("shows Lostimg as an LST image host only when configured in image hosting", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: { - LostimgEnabled: true, - LostimgAPI: "secret", - }, - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - LST: { - LinkDirName: "", - APIKey: "tracker-token", - ImageHost: "", - Anon: false, - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["LST"], - GetImageHostPolicyMetadata: async () => ({ - TrackerUploadHosts: { LST: ["lostimg"] }, - OwnedHosts: { lostimg: "LST" }, - }), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("LST", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("LST", { selector: ".settings-card__summary-name" })); - - const imageHostSelect = screen.getByLabelText("Image host") as HTMLSelectElement; - expect(Array.from(imageHostSelect.options).map((option) => option.value)).toContain("lostimg"); - }); - - it("shows configured global hosts for LST when Lostimg is disabled", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: { - Host1: "imgbb", - LostimgEnabled: false, - LostimgAPI: "", - }, - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - LST: { - LinkDirName: "", - APIKey: "tracker-token", - ImageHost: "", - Anon: false, - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["LST"], - GetImageHostPolicyMetadata: async () => ({ - TrackerUploadHosts: { LST: ["lostimg"] }, - OwnedHosts: { lostimg: "LST" }, - }), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("LST", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("LST", { selector: ".settings-card__summary-name" })); - - const values = Array.from( - (screen.getByLabelText("Image host") as HTMLSelectElement).options, - ).map((option) => option.value); - expect(values).toContain("imgbb"); - expect(values).not.toContain("lostimg"); - }); - - it("shows Reelflix as an RF image host and exposes RF image API", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: {}, - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - RF: { - LinkDirName: "", - APIKey: "", - ImgAPI: "", - ImageHost: "reelflix", - Anon: false, - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["RF"], - GetImageHostPolicyMetadata: async () => ({ - TrackerUploadHosts: { RF: ["reelflix"] }, - OwnedHosts: { reelflix: "RF" }, - }), - }, - }, - }; - - render(createElement(TrackerSettingsAdvancedHarness)); - - await waitFor(() => - expect( - screen.getByText("RF", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("RF", { selector: ".settings-card__summary-name" })); - - const imageHostSelect = screen.getByLabelText("Image host") as HTMLSelectElement; - expect(Array.from(imageHostSelect.options).map((option) => option.value)).toContain("reelflix"); - - fireEvent.change(screen.getByLabelText("Image API"), { - target: { value: "secret" }, - }); - - await waitFor(() => expect(screen.getByLabelText("Image API")).toHaveValue("secret")); - - const payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.RF?.ImgAPI).toBe("secret"); - }); - - it("shows configured global hosts for RF when Reelflix is disabled", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: { - Host1: "imgbb", - }, - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - RF: { - LinkDirName: "", - APIKey: "tracker-token", - ImgAPI: "", - ImageHost: "", - Anon: false, - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["RF"], - GetImageHostPolicyMetadata: async () => ({ - TrackerUploadHosts: { RF: ["reelflix"] }, - OwnedHosts: { reelflix: "RF" }, - }), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("RF", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("RF", { selector: ".settings-card__summary-name" })); - - const values = Array.from( - (screen.getByLabelText("Image host") as HTMLSelectElement).options, - ).map((option) => option.value); - expect(values).toContain("imgbb"); - expect(values).not.toContain("reelflix"); - }); -}); - -describe("tracker advanced fields", () => { - it("hides only the tracker advanced allowlist when advanced is closed", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - Trackers: { - DefaultTrackers: [], - PreferredTracker: "", - Trackers: { - MTV: { - FaviconURL: "https://example.test/favicon.ico", - LinkDirName: "mtv", - APIKey: "api-key", - Username: "user", - Password: "pass", - AnnounceURL: "https://example.test/announce", - Anon: false, - OTPURI: "otpauth://totp/example", - SkipIfRehash: true, - PreferMTV: true, - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => ["MTV"], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(TrackerSettingsHarness)); - - await waitFor(() => - expect( - screen.getByText("MTV", { selector: ".settings-card__summary-name" }), - ).toBeInTheDocument(), - ); - fireEvent.click(screen.getByText("MTV", { selector: ".settings-card__summary-name" })); - - expect(screen.queryByLabelText("Favicon URL")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Link dir name")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Skip if rehash")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Prefer MTV torrent")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Announce URL")).toBeInTheDocument(); - expect(screen.getByLabelText("OTP URI")).toBeInTheDocument(); - }); -}); - -describe("Image hosting settings", () => { - it("renders Lostimg as tracker-specific and keeps it out of global host priority", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: { - Host1: "", - Host2: "", - Host3: "", - Host4: "", - Host5: "", - Host6: "", - LostimgEnabled: false, - LostimgAPI: "", - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(ImageHostingHarness)); - - await waitFor(() => expect(screen.getByLabelText("LST Lostimg")).toBeInTheDocument()); - - const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; - expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("lostimg"); - - fireEvent.click(screen.getByLabelText("LST Lostimg")); - fireEvent.change(screen.getByLabelText("API key"), { - target: { value: "secret" }, - }); - - await waitFor(() => expect(screen.getByLabelText("LST Lostimg")).toBeChecked()); - - const payload = readPayload<{ - ImageHosting?: { - LostimgEnabled?: boolean; - LostimgAPI?: string; - }; - }>(); - expect(payload.ImageHosting?.LostimgEnabled).toBe(true); - expect(payload.ImageHosting?.LostimgAPI).toBe("secret"); - }); - - it("renders Reelflix as an RF image host in image hosting settings", async () => { - (globalThis as typeof globalThis & { go?: any }).go = { - guiapp: { - App: { - GetConfig: async () => - JSON.stringify({ - ImageHosting: { - Host1: "", - Host2: "", - Host3: "", - Host4: "", - Host5: "", - Host6: "", - }, - Trackers: { - Trackers: { - RF: { - ImageHost: "", - ImgAPI: "", - }, - }, - }, - }), - GetDefaultConfig: async () => JSON.stringify({}), - ListKnownTrackers: async () => [], - GetImageHostPolicyMetadata: async () => ({}), - }, - }, - }; - - render(createElement(ImageHostingHarness)); - - await waitFor(() => expect(screen.getByLabelText("RF Reelflix")).toBeInTheDocument()); - - const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; - expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("reelflix"); - - fireEvent.click(screen.getByLabelText("RF Reelflix")); - fireEvent.change(screen.getByLabelText("Image API"), { - target: { value: "secret" }, - }); - - await waitFor(() => expect(screen.getByLabelText("RF Reelflix")).toBeChecked()); - - const payload = readPayload<{ - Trackers?: { Trackers?: Record> }; - }>(); - expect(payload.Trackers?.Trackers?.RF?.ImageHost).toBe("reelflix"); - expect(payload.Trackers?.Trackers?.RF?.ImgAPI).toBe("secret"); - }); -}); diff --git a/gui/frontend/src/hooks/useSettingsState.tsx b/gui/frontend/src/hooks/useSettingsState.tsx deleted file mode 100644 index b45cbabd..00000000 --- a/gui/frontend/src/hooks/useSettingsState.tsx +++ /dev/null @@ -1,2591 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { Dispatch, SetStateAction } from "react"; -import { Button } from "../components/ui/button"; -import { PillCheckbox } from "../components/ui/checkbox"; -import { Switch } from "../components/ui/switch"; -import type { ConfigMap, ConfigValue, FieldMeta, ImageHostPolicyMetadata } from "../types"; -import { formatLabel, normalizeDefaultTrackerList } from "../utils/settings"; - -type SettingsSection = { key: string; jsonKey: string; label: string }; - -const settingsInputClass = - "h-8 rounded-md border border-white/10 bg-slate-950/45 px-2.5 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--muted)] focus:border-[var(--accent-2)] focus:ring-2 focus:ring-[rgba(53,194,193,0.18)]"; - -const settingsSelectClass = `${settingsInputClass} cursor-pointer`; -type FieldOption = NonNullable[number]; - -type UseSettingsStateOptions = { - activeTab: string; -}; - -type UseSettingsStateResult = { - configData: ConfigMap | null; - settingsLoading: boolean; - settingsDirty: boolean; - settingsSaved: string; - settingsError: string; - settingsSection: string; - settingsSections: SettingsSection[]; - showAdvancedToggle: boolean; - advancedOpen: boolean; - setSettingsSection: Dispatch>; - setSettingsAdvanced: Dispatch>>; - loadSettings: () => void; - handleSaveSettings: () => void; - renderImageHostingSection: () => JSX.Element | null; - renderTrackerSection: (advancedOpen: boolean) => JSX.Element | null; - renderTorrentClientsSection: (advancedOpen: boolean) => JSX.Element | null; - renderMapSection: ( - sectionKey: string, - sectionValue: ConfigMap, - options?: { - entriesKey?: string; - defaultKey?: string; - fieldMeta?: Record; - advancedOpen?: boolean; - }, - ) => JSX.Element; - renderField: (label: string, value: ConfigValue, path: string[], meta?: FieldMeta) => JSX.Element; - sectionFieldMeta: Record>; - updateConfigValue: (path: string[], value: ConfigValue) => void; - updateScreenshotConfigValue: (key: string, value: ConfigValue) => void; - configuredImageHosts: string[]; - screenshotConfig: ConfigMap | null; - buildSavePayload: () => string | null; - clearSettingsStatus: () => void; - markSettingsSaved: (message: string) => void; - setSettingsSavedMessage: (message: string) => void; - setSettingsErrorMessage: (message: string) => void; - resolveImageHostLabel: (value: string) => string; - knownTrackersLoading: boolean; - trackerSelectionNames: string[]; -}; - -const settingsSections: SettingsSection[] = [ - { key: "main_settings", jsonKey: "MainSettings", label: "Main" }, - { key: "image_hosting", jsonKey: "ImageHosting", label: "Image Hosting" }, - { key: "metadata", jsonKey: "Metadata", label: "Metadata" }, - { key: "screenshot_handling", jsonKey: "ScreenshotHandling", label: "Screens" }, - { key: "description_settings", jsonKey: "Description", label: "Description" }, - { key: "arr_integration", jsonKey: "ArrIntegration", label: "Arr" }, - { key: "post_upload", jsonKey: "PostUpload", label: "Post Upload" }, - { key: "trackers", jsonKey: "Trackers", label: "Trackers" }, - { key: "torrent_clients", jsonKey: "TorrentClients", label: "Torrent Clients" }, - { key: "client_setup", jsonKey: "ClientSetup", label: "Client Handling" }, - { key: "torrent_creation", jsonKey: "TorrentCreation", label: "Torrent Specific" }, -]; - -const imageHostOptions = [ - { value: "", label: "None" }, - { value: "imgbb", label: "ImgBB" }, - { value: "imgbox", label: "ImgBox" }, - { value: "pixhost", label: "Pixhost" }, - { value: "lensdump", label: "Lensdump" }, - { value: "ptscreens", label: "PTScreens" }, - { value: "onlyimage", label: "OnlyImage" }, - { value: "dalexni", label: "Dalexni" }, - { value: "zipline", label: "Zipline" }, - { value: "passtheimage", label: "PassTheImage" }, - { value: "seedpool_cdn", label: "Seedpool CDN" }, - { value: "sharex", label: "ShareX" }, - { value: "utppm", label: "UTPPM" }, -]; - -const trackerImageHostOptions = [ - ...imageHostOptions, - { value: "hdb", label: "HDB" }, - { value: "lostimg", label: "Lostimg" }, - { value: "reelflix", label: "Reelflix" }, -]; -const torrentClientTypeOptions = [ - { value: "qbit", label: "qBit" }, - { value: "watch", label: "Watch" }, -]; -const torrentClientLinkingOptions = [ - { value: "", label: "None" }, - { value: "hardlink", label: "Hardlink" }, - { value: "reflink", label: "Reflink" }, - { value: "symlink", label: "Symlink" }, -]; -const imageHostOptionLabels = new Map( - trackerImageHostOptions.map((option) => [option.value, option.label]), -); -const defaultOwnedImageHosts: Record = { - hdb: "HDB", - lostimg: "LST", - reelflix: "RF", -}; -const normalizeImageHostValue = (value: string) => value.trim().toLowerCase(); -const imageHostOptionFor = (host: string) => { - const value = normalizeImageHostValue(host); - return { value, label: imageHostOptionLabels.get(value) ?? value }; -}; - -const imageHostKeyMap: Record = { - imgbb: ["ImgBBAPI"], - lensdump: ["LensdumpAPI"], - ptscreens: ["PTScreensAPI"], - onlyimage: ["OnlyImageAPI"], - dalexni: ["DalexniAPI"], - zipline: ["ZiplineURL", "ZiplineAPIKey"], - passtheimage: ["PassTheImageAPI"], - seedpool_cdn: ["SeedpoolCDNAPI"], - sharex: ["ShareXURL", "ShareXAPIKey"], - utppm: ["UTPPMAPI"], -}; - -const stringField = (key: string, meta: Omit = {}): FieldMeta => ({ - key, - type: "string", - ...meta, -}); -const boolField = (key: string, meta: Omit = {}): FieldMeta => ({ - key, - type: "boolean", - ...meta, -}); -const numberField = (key: string, meta: Omit = {}): FieldMeta => ({ - key, - type: "number", - ...meta, -}); - -const trackerFieldMeta: Record = { - LinkDirName: stringField("LinkDirName", { label: "Link dir name", advanced: true }), - APIKey: stringField("APIKey", { label: "API key", sensitive: true }), - ApiKey: stringField("ApiKey", { label: "API key", sensitive: true }), - ApiUser: stringField("ApiUser", { label: "API user", sensitive: true }), - Username: stringField("Username", { label: "Username" }), - Password: stringField("Password", { label: "Password", sensitive: true }), - Passkey: stringField("Passkey", { label: "Passkey", sensitive: true }), - AnnounceURL: stringField("AnnounceURL", { - label: "Announce URL", - sensitive: true, - }), - MyAnnounceURL: stringField("MyAnnounceURL", { - label: "My announce URL", - sensitive: true, - }), - URL: stringField("URL", { label: "URL" }), - FaviconURL: stringField("FaviconURL", { label: "Favicon URL", advanced: true }), - UploaderName: stringField("UploaderName", { label: "Uploader name" }), - UploaderStatus: boolField("UploaderStatus", { label: "Uploader status" }), - CustomLayout: stringField("CustomLayout", { label: "Custom layout" }), - TagForCustomRelease: stringField("TagForCustomRelease", { label: "Tag for custom release" }), - CheckForRules: boolField("CheckForRules", { label: "Check for rules" }), - ModQ: boolField("ModQ", { label: "Mod queue" }), - Draft: boolField("Draft", { label: "Draft" }), - DraftDefault: boolField("DraftDefault", { label: "Draft default" }), - Anon: boolField("Anon", { label: "Anonymous" }), - ShowGroupIfAnon: boolField("ShowGroupIfAnon", { label: "Show group if anon" }), - BhdRSSKey: stringField("BhdRSSKey", { label: "BHD RSS key", sensitive: true }), - CheckRequests: boolField("CheckRequests", { label: "Check requests" }), - FullMediainfo: boolField("FullMediainfo", { label: "Full mediainfo" }), - ImgRehost: boolField("ImgRehost", { label: "Image rehost" }), - ImageHost: stringField("ImageHost", { - label: "Image host", - options: imageHostOptions, - }), - TorrentClient: stringField("TorrentClient", { label: "Torrent client", advanced: true }), - UseSpanishTitle: boolField("UseSpanishTitle", { label: "Use Spanish title" }), - UseItalianTitle: boolField("UseItalianTitle", { label: "Use Italian title" }), - OTPURI: stringField("OTPURI", { label: "OTP URI", sensitive: true }), - SkipIfRehash: boolField("SkipIfRehash", { label: "Skip if rehash", advanced: true }), - PreferMTV: boolField("PreferMTV", { label: "Prefer MTV torrent", advanced: true }), - PTGenAPI: stringField("PTGenAPI", { label: "PTGen API", sensitive: true }), - AddWebSourceToDesc: boolField("AddWebSourceToDesc", { - label: "Add web source to desc", - }), - ImageCount: numberField("ImageCount", { label: "Image count" }), - Channel: stringField("Channel", { label: "Channel" }), - ImgAPI: stringField("ImgAPI", { label: "Image API", sensitive: true }), - PronfoAPIKey: stringField("PronfoAPIKey", { - label: "Pronfo API key", - sensitive: true, - }), - PronfoTheme: stringField("PronfoTheme", { label: "Pronfo theme" }), - PronfoRAPIID: stringField("PronfoRAPIID", { label: "Pronfo RAPI ID" }), - APIUpload: boolField("APIUpload", { label: "API upload" }), - Exclusive: boolField("Exclusive", { label: "Exclusive" }), - LoginQuestion: stringField("LoginQuestion", { - label: "Login question", - sensitive: true, - }), - LoginAnswer: stringField("LoginAnswer", { - label: "Login answer", - sensitive: true, - }), - UserID: stringField("UserID", { label: "User ID", sensitive: true }), - Filebrowser: stringField("Filebrowser", { label: "Filebrowser" }), -}; - -const trackerSchemas: Record = { - A4K: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - ACM: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - AITHER: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - ANT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - AR: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.AnnounceURL, - ], - ASC: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.UploaderStatus, - trackerFieldMeta.CustomLayout, - trackerFieldMeta.AnnounceURL, - ], - AZ: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.CheckForRules, - ], - BHD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.BhdRSSKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.DraftDefault, - trackerFieldMeta.Anon, - ], - BHDTV: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.MyAnnounceURL, - trackerFieldMeta.Anon, - ], - BJS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.ShowGroupIfAnon, - ], - BLU: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - BT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - BTN: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.URL, - trackerFieldMeta.OTPURI, - ], - CBR: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - trackerFieldMeta.TagForCustomRelease, - ], - CZ: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.CheckForRules, - ], - CZT: [trackerFieldMeta.FaviconURL, trackerFieldMeta.LinkDirName, trackerFieldMeta.Passkey], - DC: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - DP: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - EMUW: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.UseSpanishTitle, - ], - FF: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.CheckRequests, - trackerFieldMeta.FullMediainfo, - ], - FL: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Passkey, - trackerFieldMeta.UploaderName, - trackerFieldMeta.Anon, - ], - FRIKI: [trackerFieldMeta.FaviconURL, trackerFieldMeta.LinkDirName, trackerFieldMeta.APIKey], - GPW: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - ], - HDB: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Passkey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.ImgRehost, - ], - HDS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.FullMediainfo, - ], - HDT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.URL, - trackerFieldMeta.Anon, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.FullMediainfo, - ], - HHD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - IHD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - IS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - ITT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - LCD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.TagForCustomRelease, - ], - LDU: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - LST: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - trackerFieldMeta.Draft, - ], - LT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - LUME: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - MNS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - MTV: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.OTPURI, - trackerFieldMeta.SkipIfRehash, - trackerFieldMeta.PreferMTV, - ], - NBL: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - ], - OE: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - OTW: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.ModQ, - trackerFieldMeta.Anon, - ], - PHD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.CheckForRules, - ], - PT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - PTP: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.AddWebSourceToDesc, - trackerFieldMeta.ApiUser, - trackerFieldMeta.ApiKey, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.OTPURI, - ], - PTS: [trackerFieldMeta.FaviconURL, trackerFieldMeta.LinkDirName, trackerFieldMeta.AnnounceURL], - PTT: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - R4E: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - RAS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - RF: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.ImgAPI, - trackerFieldMeta.Anon, - ], - RTF: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - SAM: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.TagForCustomRelease, - ], - SHRI: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.UseItalianTitle, - ], - SP: [trackerFieldMeta.FaviconURL, trackerFieldMeta.LinkDirName, trackerFieldMeta.APIKey], - SPD: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Channel, - ], - STC: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - THR: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.ImgAPI, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.PronfoAPIKey, - trackerFieldMeta.PronfoTheme, - trackerFieldMeta.PronfoRAPIID, - trackerFieldMeta.Anon, - ], - TIK: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - TL: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIUpload, - trackerFieldMeta.Passkey, - trackerFieldMeta.Anon, - trackerFieldMeta.ImgRehost, - trackerFieldMeta.FullMediainfo, - ], - TLZ: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - TOS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - trackerFieldMeta.Exclusive, - ], - TTR: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - TVC: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.ImageCount, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Anon, - ], - ULCX: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - UTP: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - YUS: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - ], - ZNTH: [ - trackerFieldMeta.FaviconURL, - trackerFieldMeta.LinkDirName, - trackerFieldMeta.APIKey, - trackerFieldMeta.Anon, - trackerFieldMeta.ModQ, - ], - MANUAL: [trackerFieldMeta.FaviconURL, trackerFieldMeta.Filebrowser], -}; - -const trackerHasAdvancedFields = Object.values(trackerSchemas).some((fields) => - fields.some((field) => field.advanced), -); - -const REDACTED_VALUE = "[REDACTED]"; -const ENCRYPTED_SECRET_PREFIX = "upbrr-enc:v1:"; -const trackerActivationKeys = new Set([ - "APIKey", - "ApiUser", - "ApiKey", - "Username", - "Password", - "Passkey", - "AnnounceURL", - "MyAnnounceURL", - "BhdRSSKey", - "OTPURI", - "PTGenAPI", - "ImageHost", - "ImgAPI", - "TorrentClient", - "PronfoAPIKey", - "LoginQuestion", - "LoginAnswer", - "UserID", - "Filebrowser", -]); - -function hasConfiguredTrackerValue( - value: ConfigValue | undefined, - baseline: ConfigValue | undefined, -): boolean { - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - return baseline !== value; - } - if (typeof value === "number") { - return value !== 0 && baseline !== value; - } - if (typeof value === "boolean") { - return value && baseline !== value; - } - if (Array.isArray(value)) { - if (value.length === 0) { - return false; - } - if (Array.isArray(baseline) && JSON.stringify(value) === JSON.stringify(baseline)) { - return false; - } - return true; - } - if (value && typeof value === "object") { - if (Object.keys(value).length === 0) { - return false; - } - if ( - baseline && - typeof baseline === "object" && - JSON.stringify(value) === JSON.stringify(baseline) - ) { - return false; - } - return true; - } - return false; -} -const sensitiveKeyHints = [ - "password", - "passkey", - "token", - "api", - "key", - "secret", - "cookie", - "session", - "otp", - "announce_url", - "announceurl", -]; - -const sectionFieldMeta: Record> = { - ImageHosting: { - LostimgAPI: stringField("LostimgAPI", { label: "API key", sensitive: true }), - }, - MainSettings: { - InputHistoryLimit: { key: "InputHistoryLimit", label: "Input history limit", type: "number" }, - UseFavicons: { key: "UseFavicons", label: "Use favicons" }, - FaviconOnly: { key: "FaviconOnly", label: "Favicon only" }, - SceneDetection: { key: "SceneDetection", label: "Scene detection (srrdb)" }, - }, - Metadata: { - BTNAPI: { key: "BTNAPI", advanced: true, sensitive: true }, - SkipAutoTorrent: { key: "SkipAutoTorrent", advanced: true }, - SkipTrackerFilenameLookup: { key: "SkipTrackerFilenameLookup", advanced: true }, - UserOverrides: { key: "UserOverrides", advanced: true }, - BlurayScore: { key: "BlurayScore", advanced: true }, - BluraySingleScore: { key: "BluraySingleScore", advanced: true }, - CheckPredb: { key: "CheckPredb", advanced: true }, - }, - ScreenshotHandling: { - MaxMenuItems: { key: "MaxMenuItems", label: "Maximum DVD menu images", type: "number" }, - ProcessLimit: { key: "ProcessLimit", advanced: true }, - MaxConcurrentUploads: { key: "MaxConcurrentUploads", advanced: true }, - FFmpegLimit: { key: "FFmpegLimit", advanced: true }, - FFmpegCompression: { key: "FFmpegCompression", advanced: true }, - TonemapAlgorithm: { key: "TonemapAlgorithm", advanced: true }, - Desat: { key: "Desat", advanced: true }, - }, - Description: { - LogoSize: { key: "LogoSize", advanced: true }, - LogoLanguage: { key: "LogoLanguage", advanced: true }, - CharLimit: { key: "CharLimit", advanced: true }, - FileLimit: { key: "FileLimit", advanced: true }, - ProcessLimit: { key: "ProcessLimit", advanced: true }, - CustomSignature: { key: "CustomSignature", advanced: true }, - }, - ArrIntegration: { - SonarrURL1: { key: "SonarrURL1", advanced: true }, - SonarrAPIKey1: { key: "SonarrAPIKey1", advanced: true, sensitive: true }, - SonarrURL2: { key: "SonarrURL2", advanced: true }, - SonarrAPIKey2: { key: "SonarrAPIKey2", advanced: true, sensitive: true }, - SonarrURL3: { key: "SonarrURL3", advanced: true }, - SonarrAPIKey3: { key: "SonarrAPIKey3", advanced: true, sensitive: true }, - RadarrURL1: { key: "RadarrURL1", advanced: true }, - RadarrAPIKey1: { key: "RadarrAPIKey1", advanced: true, sensitive: true }, - RadarrURL2: { key: "RadarrURL2", advanced: true }, - RadarrAPIKey2: { key: "RadarrAPIKey2", advanced: true, sensitive: true }, - RadarrURL3: { key: "RadarrURL3", advanced: true }, - RadarrAPIKey3: { key: "RadarrAPIKey3", advanced: true, sensitive: true }, - EmbyDir: { key: "EmbyDir", advanced: true }, - EmbyTVDir: { key: "EmbyTVDir", advanced: true }, - }, - TorrentCreation: {}, - PostUpload: { - InjectDelay: { key: "InjectDelay", advanced: true }, - MaxConcurrentTrackers: { key: "MaxConcurrentTrackers", advanced: true }, - }, - Logging: {}, - TorrentClients: { - Type: stringField("Type", { label: "Type", options: torrentClientTypeOptions }), - WatchFolder: stringField("WatchFolder", { label: "Watch folder" }), - StorageDir: stringField("StorageDir", { label: "Storage directory" }), - QuiProxyURL: stringField("QuiProxyURL", { label: "Qui proxy URL", sensitive: true }), - QbitURL: stringField("QbitURL", { label: "qBit URL" }), - QbitPort: numberField("QbitPort", { label: "qBit port" }), - QbitUser: stringField("QbitUser", { label: "qBit user", sensitive: true }), - QbitPass: stringField("QbitPass", { label: "qBit pass", sensitive: true }), - QbitCategoryValue: stringField("QbitCategoryValue", { label: "qBit category" }), - QbitTag: stringField("QbitTag", { label: "qBit tag" }), - QbitCrossCategory: stringField("QbitCrossCategory", { label: "qBit cross category" }), - QbitCrossTag: stringField("QbitCrossTag", { label: "qBit cross tag" }), - UseTrackerAsTag: boolField("UseTrackerAsTag", { label: "Use tracker as tag" }), - Linking: stringField("Linking", { - label: "Linking", - options: torrentClientLinkingOptions, - }), - AllowFallback: boolField("AllowFallback", { label: "Allow link fallback" }), - LinkedFolder: stringField("LinkedFolder", { label: "Linked folder" }), - LocalPath: stringField("LocalPath", { label: "Local path" }), - RemotePath: stringField("RemotePath", { label: "Remote path" }), - AutomaticManagementPaths: stringField("AutomaticManagementPaths", { - label: "Automatic management paths", - }), - VerifyWebUICertificate: boolField("VerifyWebUICertificate", { - label: "Verify WebUI certificate", - advanced: true, - }), - }, -}; - -const isSensitiveKeyName = (key: string) => { - const lower = key.toLowerCase(); - return sensitiveKeyHints.some((hint) => lower.includes(hint)); -}; - -const buildPathKey = (path: string[]) => path.join("."); - -const isEncryptedSecretEnvelope = (value: string) => value.startsWith(ENCRYPTED_SECRET_PREFIX); - -/** Masks secret-bearing config strings while retaining originals by config path for save payloads. */ -const maskSensitiveConfig = (input: ConfigMap) => { - const originals: Record = {}; - const walk = (value: ConfigValue, path: string[]): ConfigValue => { - if (value === null || value === undefined) return value; - if (Array.isArray(value)) { - return value.map((entry, index) => walk(entry, [...path, String(index)])); - } - if (typeof value === "object") { - const next: ConfigMap = {}; - Object.entries(value).forEach(([key, child]) => { - next[key] = walk(child, [...path, key]); - }); - return next; - } - if (typeof value === "string") { - const key = path[path.length - 1] || ""; - if (value && (isSensitiveKeyName(key) || isEncryptedSecretEnvelope(value))) { - originals[buildPathKey(path)] = value; - return REDACTED_VALUE; - } - return value; - } - return value; - }; - - return { masked: walk(input, []) as ConfigMap, originals }; -}; - -const restoreSensitiveConfig = (input: ConfigMap, originals: Record) => { - const walk = (value: ConfigValue, path: string[]): ConfigValue => { - if (value === null || value === undefined) return value; - if (Array.isArray(value)) { - return value.map((entry, index) => walk(entry, [...path, String(index)])); - } - if (typeof value === "object") { - const next: ConfigMap = {}; - Object.entries(value).forEach(([key, child]) => { - next[key] = walk(child, [...path, key]); - }); - return next; - } - if (typeof value === "string") { - if (value === REDACTED_VALUE) { - const original = originals[buildPathKey(path)]; - if (original !== undefined) { - return original; - } - } - return value; - } - return value; - }; - - return walk(input, []) as ConfigMap; -}; - -const legacyTorrentClientKeys = [ - "Type", - "TorrentClient", - "URL", - "WatchFolder", - "StorageDir", - "Username", - "Password", - "Category", - "Tags", - "TLSSkipVerify", - "QbitTagsValue", -]; - -const qbitDefaultClient = (): ConfigMap => ({ - Type: "qbit", - QuiProxyURL: "", - QbitCategoryValue: "", - QbitTag: "", - QbitCrossCategory: "", - QbitCrossTag: "", - UseTrackerAsTag: false, - Linking: "", - AllowFallback: true, - LinkedFolder: [], - LocalPath: [], - RemotePath: [], - AutomaticManagementPaths: [], - VerifyWebUICertificate: true, -}); - -const qbitDirectDisabledValues: Readonly> = { - QbitURL: "", - QbitPort: 0, - QbitUser: "", - QbitPass: "", - URL: "", - Username: "", - Password: "", - QuiProxyURL: "", -}; - -const normalizeStringArray = (value: ConfigValue) => { - if (Array.isArray(value)) { - return value.map((entry) => String(entry ?? "").trim()).filter(Boolean); - } - if (typeof value === "string") { - return value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - } - return []; -}; - -const normalizeTorrentClientType = (client: ConfigMap) => { - const directType = typeof client.Type === "string" ? client.Type.trim() : ""; - if (directType) { - return directType.toLowerCase(); - } - const legacyType = typeof client.TorrentClient === "string" ? client.TorrentClient.trim() : ""; - if (legacyType) { - return legacyType.toLowerCase(); - } - return "qbit"; -}; - -/** - * Normalizes a torrent client before save, migrating legacy qBit fields to the - * canonical qBit keys while preserving non-qBit client configs. - */ -export const normalizeTorrentClientForSave = (client: ConfigMap) => { - const next = { ...client }; - if (normalizeTorrentClientType(next) !== "qbit") { - return next; - } - - if (!next.QbitURL && typeof next.URL === "string") next.QbitURL = next.URL; - if (!next.QbitUser && typeof next.Username === "string") next.QbitUser = next.Username; - if (!next.QbitPass && typeof next.Password === "string") next.QbitPass = next.Password; - if (!next.QbitCategoryValue && typeof next.Category === "string") { - next.QbitCategoryValue = next.Category; - } - if (!next.QbitTag) { - if (Array.isArray(next.Tags)) { - next.QbitTag = normalizeStringArray(next.Tags).join(","); - } else if (Array.isArray(next.QbitTagsValue)) { - next.QbitTag = normalizeStringArray(next.QbitTagsValue).join(","); - } - } - if (next.VerifyWebUICertificate === undefined && typeof next.TLSSkipVerify === "boolean") { - next.VerifyWebUICertificate = !next.TLSSkipVerify; - } - - legacyTorrentClientKeys.forEach((key) => { - delete next[key]; - }); - - return next; -}; - -/** - * Builds the next qBit direct-connection state for the settings toggle. - * Enabling seeds host defaults; disabling clears direct, proxy, and legacy - * credential fields so the client no longer attempts a direct qBit connection. - */ -export const nextQbitDirectState = (client: ConfigMap, enabled: boolean): ConfigMap => { - if (enabled) { - return { - ...client, - QbitURL: - typeof client.QbitURL === "string" && client.QbitURL.trim() !== "" - ? client.QbitURL - : "http://127.0.0.1", - QbitPort: typeof client.QbitPort === "number" && client.QbitPort > 0 ? client.QbitPort : 8080, - }; - } - return { ...client, ...qbitDirectDisabledValues }; -}; - -/** - * Normalizes all configured torrent client entries in a settings payload before - * serializing it for the backend. - */ -export const normalizeTorrentClientsForSave = (input: ConfigMap) => { - const clients = input.TorrentClients; - if (!clients || typeof clients !== "object" || Array.isArray(clients)) { - return input; - } - - const nextClients: ConfigMap = {}; - Object.entries(clients as ConfigMap).forEach(([name, value]) => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return; - } - nextClients[name] = normalizeTorrentClientForSave(value as ConfigMap); - }); - - return { ...input, TorrentClients: nextClients }; -}; - -const normalizeCZTTrackerForSave = (input: ConfigMap) => { - const trackerRoot = input.Trackers; - if (!trackerRoot || typeof trackerRoot !== "object" || Array.isArray(trackerRoot)) { - return input; - } - const trackerEntries = (trackerRoot as ConfigMap).Trackers; - if (!trackerEntries || typeof trackerEntries !== "object" || Array.isArray(trackerEntries)) { - return input; - } - - let changed = false; - const nextEntries: ConfigMap = {}; - Object.entries(trackerEntries as ConfigMap).forEach(([name, value]) => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - nextEntries[name] = value; - return; - } - if (name.trim().toUpperCase() !== "CZT") { - nextEntries[name] = value; - return; - } - const next = { ...(value as ConfigMap) }; - if ("APIKey" in next) { - delete next.APIKey; - changed = true; - } - if ("URL" in next) { - delete next.URL; - changed = true; - } - if ("AnnounceURL" in next) { - delete next.AnnounceURL; - changed = true; - } - nextEntries[name] = next; - }); - - if (!changed) { - return input; - } - return { - ...input, - Trackers: { - ...(trackerRoot as ConfigMap), - Trackers: nextEntries, - }, - }; -}; - -/** - * Owns settings-screen state, Wails config loading, sensitive-value masking, - * render helpers, and save payload construction for tabs that need config data. - * Save payloads restore masked secrets before serialization. - */ -export const useSettingsState = (options: UseSettingsStateOptions): UseSettingsStateResult => { - const { activeTab } = options; - const [configData, setConfigData] = useState(null); - const [defaultConfig, setDefaultConfig] = useState(null); - const [knownTrackers, setKnownTrackers] = useState([]); - const [imageHostPolicyMetadata, setImageHostPolicyMetadata] = - useState(null); - const [knownTrackersLoading, setKnownTrackersLoading] = useState(false); - const [trackerAddSelection, setTrackerAddSelection] = useState(""); - const [manualTrackerEntries, setManualTrackerEntries] = useState>({}); - const [settingsTrackerPanels, setSettingsTrackerPanels] = useState>({}); - const [defaultTrackersPanelOpen, setDefaultTrackersPanelOpen] = useState(false); - const [settingsLoading, setSettingsLoading] = useState(false); - const [settingsError, setSettingsError] = useState(""); - const [settingsSaved, setSettingsSaved] = useState(""); - const [settingsDirty, setSettingsDirty] = useState(false); - const [settingsSection, setSettingsSection] = useState(settingsSections[0].key); - const [settingsAdvanced, setSettingsAdvanced] = useState>({}); - const [sensitiveValues, setSensitiveValues] = useState>({}); - - const configuredImageHosts = useMemo(() => { - if (!configData || !configData.ImageHosting || typeof configData.ImageHosting !== "object") { - return [] as string[]; - } - if (Array.isArray(configData.ImageHosting)) { - return [] as string[]; - } - const imageCfg = configData.ImageHosting as ConfigMap; - const hostFields = ["Host1", "Host2", "Host3", "Host4", "Host5", "Host6"]; - const hosts: string[] = []; - hostFields.forEach((field) => { - const value = String(imageCfg[field] ?? "").trim(); - if (!value) return; - if (!hosts.includes(value)) { - hosts.push(value); - } - }); - return hosts; - }, [configData]); - - const screenshotConfig = useMemo(() => { - if ( - !configData || - !configData.ScreenshotHandling || - typeof configData.ScreenshotHandling !== "object" - ) { - return null; - } - if (Array.isArray(configData.ScreenshotHandling)) { - return null; - } - return configData.ScreenshotHandling as ConfigMap; - }, [configData]); - - const torrentClientOptions = useMemo(() => { - if ( - !configData || - !configData.TorrentClients || - typeof configData.TorrentClients !== "object" || - Array.isArray(configData.TorrentClients) - ) { - return []; - } - - return Object.entries(configData.TorrentClients as ConfigMap) - .filter( - ([name, value]) => - name.trim() !== "" && value && typeof value === "object" && !Array.isArray(value), - ) - .map(([name]) => ({ value: name, label: name })); - }, [configData]); - - const effectiveSectionFieldMeta = useMemo>>(() => { - const clientOptions = [{ value: "", label: "" }, ...torrentClientOptions]; - return { - ...sectionFieldMeta, - ClientSetup: { - ...(sectionFieldMeta.ClientSetup ?? {}), - DefaultClient: stringField("DefaultClient", { - label: "Default client", - options: clientOptions, - }), - InjectClients: stringField("InjectClients", { - label: "Injected clients", - options: clientOptions, - }), - SearchClients: stringField("SearchClients", { - label: "Searching clients", - options: clientOptions, - }), - }, - }; - }, [torrentClientOptions]); - - const setSettingsErrorMessage = (message: string) => { - setSettingsError(message); - }; - - const clearSettingsStatus = useCallback(() => { - setSettingsError(""); - setSettingsSaved(""); - }, []); - - const markSettingsSaved = (message: string) => { - setSettingsSaved(message); - setSettingsDirty(false); - }; - - const setSettingsSavedMessage = (message: string) => { - setSettingsSaved(message); - }; - - const buildSavePayload = () => { - if (!configData) { - return null; - } - const restored = normalizeCZTTrackerForSave( - normalizeTorrentClientsForSave(restoreSensitiveConfig(configData, sensitiveValues)), - ); - return JSON.stringify(restored, null, 2); - }; - - const resolveImageHostLabel = (value: string) => { - return imageHostOptionLabels.get(value) ?? value; - }; - - const buildImageHostOptions = useCallback((hosts: string[]) => { - const allowed = new Set( - hosts.map((host) => normalizeImageHostValue(host)).filter((host) => host.length > 0), - ); - const ordered = imageHostOptions.filter( - (option) => option.value === "" || allowed.has(option.value), - ); - const known = new Set(ordered.map((option) => option.value)); - const extras = Array.from(allowed) - .filter((host) => !known.has(host)) - .sort((left, right) => left.localeCompare(right)) - .map(imageHostOptionFor); - return [...ordered, ...extras]; - }, []); - - const trackerOptionsForImageHost = useCallback( - (trackerName: string) => { - const trackerKey = trackerName.trim().toUpperCase(); - if (!imageHostPolicyMetadata) { - return trackerKey === "HDB" ? trackerImageHostOptions : imageHostOptions; - } - - const policyHosts = imageHostPolicyMetadata.TrackerUploadHosts?.[trackerKey]; - const fallbackHosts = - imageHostPolicyMetadata.UploadHosts?.map((host) => normalizeImageHostValue(host)) ?? - imageHostOptions.filter((option) => option.value).map((option) => option.value); - const ownerByHost = imageHostPolicyMetadata.OwnedHosts ?? defaultOwnedImageHosts; - const imageCfg = - configData?.ImageHosting && - typeof configData.ImageHosting === "object" && - !Array.isArray(configData.ImageHosting) - ? (configData.ImageHosting as ConfigMap) - : null; - const trackerRoot = - configData?.Trackers && - typeof configData.Trackers === "object" && - !Array.isArray(configData.Trackers) - ? (configData.Trackers as ConfigMap) - : null; - const trackerEntries = - trackerRoot?.Trackers && - typeof trackerRoot.Trackers === "object" && - !Array.isArray(trackerRoot.Trackers) - ? (trackerRoot.Trackers as ConfigMap) - : null; - const trackerCfg = - trackerEntries?.[trackerKey] && - typeof trackerEntries[trackerKey] === "object" && - !Array.isArray(trackerEntries[trackerKey]) - ? (trackerEntries[trackerKey] as ConfigMap) - : null; - const globalFallbackHosts = fallbackHosts.filter((host) => !ownerByHost[host]); - const globalHosts = (configuredImageHosts.length ? configuredImageHosts : globalFallbackHosts) - .map((host) => normalizeImageHostValue(host)) - .filter((host) => host.length > 0 && !ownerByHost[host]); - const policyHostSet = new Set( - (policyHosts ?? []).map((host) => normalizeImageHostValue(host)).filter(Boolean), - ); - const policyHasGlobalHosts = Array.from(policyHostSet).some((host) => !ownerByHost[host]); - const policyAllowsGlobalFallback = - policyHostSet.size === 0 || - Array.from(policyHostSet).every((host) => host === "lostimg" || host === "reelflix"); - const hosts = globalHosts.filter( - (host) => policyAllowsGlobalFallback || (policyHasGlobalHosts && policyHostSet.has(host)), - ); - - (policyHosts ?? []).forEach((host) => { - const normalizedHost = normalizeImageHostValue(host); - const owner = ownerByHost[normalizedHost]; - if (!owner || owner.trim().toUpperCase() !== trackerKey) { - return; - } - if (normalizedHost === "lostimg" && !Boolean(imageCfg?.LostimgEnabled)) { - return; - } - if ( - normalizedHost === "reelflix" && - normalizeImageHostValue(String(trackerCfg?.ImageHost ?? "")) !== "reelflix" - ) { - return; - } - if (!hosts.includes(normalizedHost)) { - hosts.push(normalizedHost); - } - }); - - return buildImageHostOptions(hosts); - }, - [buildImageHostOptions, configData, configuredImageHosts, imageHostPolicyMetadata], - ); - - const updateConfigValue = (path: string[], value: ConfigValue) => { - setConfigData((prev) => { - if (!prev) return prev; - const clone = structuredClone(prev) as ConfigMap; - let cursor: ConfigMap = clone; - for (let i = 0; i < path.length - 1; i += 1) { - const key = path[i]; - const next = cursor[key]; - if (!next || typeof next !== "object" || Array.isArray(next)) { - cursor[key] = {}; - } - cursor = cursor[key] as ConfigMap; - } - cursor[path[path.length - 1]] = value; - setSettingsDirty(true); - return clone; - }); - - const key = path[path.length - 1] || ""; - if ( - typeof value === "string" && - (isSensitiveKeyName(key) || sensitiveValues[buildPathKey(path)] !== undefined) - ) { - setSensitiveValues((prev) => { - const next = { ...prev }; - const pathKey = buildPathKey(path); - if (value === REDACTED_VALUE) { - return prev; - } - if (!value) { - delete next[pathKey]; - return next; - } - next[pathKey] = value; - return next; - }); - } - }; - - const updateScreenshotConfigValue = (key: string, value: ConfigValue) => { - updateConfigValue(["ScreenshotHandling", key], value); - }; - - const removeConfigKey = (path: string[], key: string) => { - setConfigData((prev) => { - if (!prev) return prev; - const clone = structuredClone(prev) as ConfigMap; - let cursor: ConfigMap = clone; - for (let i = 0; i < path.length; i += 1) { - cursor = cursor[path[i]] as ConfigMap; - if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) { - return prev; - } - } - if (!Object.prototype.hasOwnProperty.call(cursor, key)) { - return prev; - } - delete cursor[key]; - setSettingsDirty(true); - return clone; - }); - }; - - const addConfigKey = (path: string[], key: string, value: ConfigValue) => { - if (!key.trim()) return; - setConfigData((prev) => { - if (!prev) return prev; - const clone = structuredClone(prev) as ConfigMap; - let cursor: ConfigMap = clone; - for (let i = 0; i < path.length; i += 1) { - const step = path[i]; - const next = cursor[step]; - if (!next || typeof next !== "object" || Array.isArray(next)) { - cursor[step] = {}; - } - cursor = cursor[step] as ConfigMap; - } - if (cursor[key] !== undefined) return prev; - cursor[key] = value; - setSettingsDirty(true); - return clone; - }); - }; - - const loadSettings = useCallback(async () => { - clearSettingsStatus(); - const getConfig = globalThis.go?.guiapp?.App?.GetConfig; - if (!getConfig) { - setSettingsError("Settings are unavailable in this build."); - return; - } - setSettingsLoading(true); - try { - const result = await getConfig(); - const parsed = JSON.parse(result) as ConfigMap; - const masked = maskSensitiveConfig(parsed); - setConfigData(masked.masked); - setSensitiveValues(masked.originals); - setManualTrackerEntries({}); - setSettingsDirty(false); - } catch (err) { - setSettingsError(String(err)); - } finally { - setSettingsLoading(false); - } - }, [clearSettingsStatus]); - - const loadDefaultConfig = useCallback(async () => { - const getDefaultConfig = globalThis.go?.guiapp?.App?.GetDefaultConfig; - if (!getDefaultConfig) { - return; - } - try { - const result = await getDefaultConfig(); - const parsed = JSON.parse(result) as ConfigMap; - setDefaultConfig(parsed); - } catch (_err) { - // Silently fail if we can't load default config; it's optional for tracker comparison - } - }, []); - - const loadKnownTrackers = useCallback(async () => { - if (knownTrackersLoading) return; - const listKnown = globalThis.go?.guiapp?.App?.ListKnownTrackers; - if (!listKnown) return; - setKnownTrackersLoading(true); - try { - const result = await listKnown(); - if (Array.isArray(result)) { - setKnownTrackers(result.map((entry) => String(entry))); - } - } catch (err) { - setSettingsError(String(err)); - } finally { - setKnownTrackersLoading(false); - } - }, [knownTrackersLoading]); - - const loadImageHostPolicyMetadata = useCallback(async () => { - const getMetadata = globalThis.go?.guiapp?.App?.GetImageHostPolicyMetadata; - if (!getMetadata) return; - try { - const result = await getMetadata(); - if (result && typeof result === "object") { - setImageHostPolicyMetadata(result); - } - } catch (err) { - setSettingsError(String(err)); - } - }, []); - - const handleSaveSettings = async () => { - clearSettingsStatus(); - const saveConfig = globalThis.go?.guiapp?.App?.SaveConfig; - if (!saveConfig) { - setSettingsError("Settings are unavailable in this build."); - return; - } - const payload = buildSavePayload(); - if (!payload) { - setSettingsError("Settings are not loaded."); - return; - } - setSettingsLoading(true); - try { - await saveConfig(payload); - markSettingsSaved("Settings saved and applied."); - } catch (err) { - setSettingsError(String(err)); - } finally { - setSettingsLoading(false); - } - }; - - useEffect(() => { - if ( - (activeTab === "input" || - activeTab === "tracker" || - activeTab === "dupes" || - activeTab === "settings" || - activeTab === "logging" || - activeTab === "upload" || - activeTab === "upload_images") && - !configData - ) { - loadSettings(); - } - }, [activeTab, configData, loadSettings]); - - useEffect(() => { - if ( - (activeTab === "settings" || activeTab === "input" || activeTab === "upload") && - !defaultConfig - ) { - loadDefaultConfig(); - } - }, [activeTab, defaultConfig, loadDefaultConfig]); - - useEffect(() => { - if (activeTab === "settings" && knownTrackers.length === 0 && !knownTrackersLoading) { - loadKnownTrackers(); - } - }, [activeTab, knownTrackers.length, knownTrackersLoading, loadKnownTrackers]); - - useEffect(() => { - if (activeTab === "settings" && !imageHostPolicyMetadata) { - loadImageHostPolicyMetadata(); - } - }, [activeTab, imageHostPolicyMetadata, loadImageHostPolicyMetadata]); - - useEffect(() => { - if ((activeTab === "screenshots" || activeTab === "upload_images") && !configData) { - loadSettings(); - } - }, [activeTab, configData, loadSettings]); - - const advancedOpen = settingsAdvanced[settingsSection] ?? false; - const showAdvancedToggle = (() => { - if (settingsSection === "trackers") return trackerHasAdvancedFields; - const section = settingsSections.find((item) => item.key === settingsSection); - if (!section) return false; - const meta = effectiveSectionFieldMeta[section.jsonKey]; - if (!meta) return false; - return Object.values(meta).some((field) => field.advanced); - })(); - - const renderArrayEditor = ( - value: ConfigValue[], - path: string[], - meta?: FieldMeta, - displayLabel?: string, - ) => { - const options = meta?.options ?? []; - const optionValueFor = (entry: ConfigValue) => (entry === null ? "" : String(entry ?? "")); - const optionsFor = (entry: ConfigValue) => { - const selected = optionValueFor(entry); - if (!selected || options.some((option) => option.value === selected)) { - return options; - } - return [...options, { value: selected, label: selected }]; - }; - const newItemValue = options.find((option) => option.value !== "")?.value ?? ""; - - return ( -
- {value.map((entry, index) => ( -
- {options.length > 0 ? ( - - ) : ( - { - const updated = [...value]; - updated[index] = event.target.value; - updateConfigValue(path, updated); - }} - /> - )} - -
- ))} - -
- ); - }; - - const renderField = (label: string, value: ConfigValue, path: string[], meta?: FieldMeta) => { - const displayLabel = meta?.label ?? formatLabel(label); - const typeHint = meta?.type; - if (Array.isArray(value)) { - return ( -
- {displayLabel} - {renderArrayEditor(value, path, meta, displayLabel)} -
- ); - } - if (meta?.options && meta.options.length > 0) { - const selectedValue = value === null ? "" : String(value ?? ""); - const options = meta.options.some((option) => option.value === selectedValue) - ? meta.options - : [...meta.options, { value: selectedValue, label: selectedValue }]; - return ( - - ); - } - if (typeHint === "boolean" || typeof value === "boolean") { - return ( -
- {displayLabel} - updateConfigValue(path, event.target.checked)} - /> -
- ); - } - if (typeHint === "number" || typeof value === "number") { - const numericValue = typeof value === "number" && Number.isFinite(value) ? value : 0; - return ( - - ); - } - if (value && typeof value === "object") { - return ( -
-
{displayLabel}
-
- {Object.entries(value).map(([childKey, childValue]) => - renderField(childKey, childValue, [...path, childKey]), - )} -
-
- ); - } - - return ( - - ); - }; - - const renderMapSection = ( - sectionKey: string, - sectionValue: ConfigMap, - options?: { - entriesKey?: string; - defaultKey?: string; - fieldMeta?: Record; - advancedOpen?: boolean; - }, - ) => { - const entriesRoot = options?.entriesKey - ? (sectionValue[options.entriesKey] as ConfigMap) || {} - : sectionValue; - const entries = Object.entries(entriesRoot).filter( - ([, value]) => value && typeof value === "object" && !Array.isArray(value), - ) as Array<[string, ConfigMap]>; - const defaultKey = options?.defaultKey; - const fieldMeta = options?.fieldMeta || {}; - const advancedOpen = options?.advancedOpen ?? false; - - return ( -
- {defaultKey ? ( -
-
{formatLabel(defaultKey)}
- {renderField(defaultKey, sectionValue[defaultKey] as ConfigValue, [ - sectionKey, - defaultKey, - ])} -
- ) : null} - -
-

Entries

- -
- -
- {entries.length === 0 ? ( -

No entries yet.

- ) : ( - entries.map(([key, value]) => ( -
-
-

{key}

- -
-
- {Object.entries(value) - .filter(([childKey]) => { - const meta = fieldMeta[childKey]; - if (meta?.advanced && !advancedOpen) return false; - return true; - }) - .map(([childKey, childValue]) => - renderField( - childKey, - childValue, - [sectionKey, options?.entriesKey || "", key, childKey].filter(Boolean), - fieldMeta[childKey], - ), - )} -
-
- )) - )} -
-
- ); - }; - - const renderTorrentClientsSection = (advancedOpen: boolean) => { - if ( - !configData || - !configData.TorrentClients || - typeof configData.TorrentClients !== "object" || - Array.isArray(configData.TorrentClients) - ) { - return null; - } - - const clients = Object.entries(configData.TorrentClients as ConfigMap).filter( - ([, value]) => value && typeof value === "object" && !Array.isArray(value), - ) as Array<[string, ConfigMap]>; - const meta = effectiveSectionFieldMeta.TorrentClients || {}; - const valueFor = (client: ConfigMap, primary: string, fallback?: string) => { - const primaryValue = client[primary]; - if (primaryValue !== undefined && primaryValue !== null && primaryValue !== "") { - return primaryValue; - } - return fallback ? client[fallback] : primaryValue; - }; - const arrayFor = (client: ConfigMap, key: string) => { - const value = client[key]; - return Array.isArray(value) ? value : []; - }; - const qbitTagFor = (client: ConfigMap) => { - const direct = client.QbitTag; - if (typeof direct === "string" && direct.trim() !== "") return direct; - const qbitTags = normalizeStringArray(client.QbitTagsValue); - if (qbitTags.length > 0) return qbitTags.join(","); - return normalizeStringArray(client.Tags).join(","); - }; - const hasDirectConfig = (client: ConfigMap) => - ["QbitURL", "QbitPort", "QbitUser", "QbitPass", "URL", "Username", "Password"].some((key) => { - const value = client[key]; - if (typeof value === "number") return value > 0; - return typeof value === "string" && value.trim() !== ""; - }); - const setQbitDirect = (name: string, enabled: boolean) => { - const client = clients.find(([clientName]) => clientName === name)?.[1]; - if (!client) { - return; - } - const nextClient = nextQbitDirectState(client, enabled); - for (const key of [ - "QbitURL", - "QbitPort", - "QbitUser", - "QbitPass", - "URL", - "Username", - "Password", - "QuiProxyURL", - ]) { - if (client[key] === nextClient[key]) { - continue; - } - updateConfigValue(["TorrentClients", name, key], nextClient[key]); - } - }; - - return ( -
-
-

Entries

- -
- -
- {clients.length === 0 ? ( -

No entries yet.

- ) : ( - clients.map(([name, client]) => { - const clientType = normalizeTorrentClientType(client); - const watchClient = clientType === "watch"; - const directEnabled = !watchClient && hasDirectConfig(client); - return ( -
-
-

{name}

- -
-
- {renderField( - "Type", - valueFor(client, "Type", "TorrentClient") ?? "qbit", - ["TorrentClients", name, "Type"], - meta.Type, - )} - {watchClient - ? renderField( - "WatchFolder", - client.WatchFolder ?? "", - ["TorrentClients", name, "WatchFolder"], - meta.WatchFolder, - ) - : null} - {watchClient - ? renderField( - "StorageDir", - client.StorageDir ?? "", - ["TorrentClients", name, "StorageDir"], - meta.StorageDir, - ) - : null} - {!watchClient - ? renderField( - "QuiProxyURL", - client.QuiProxyURL ?? "", - ["TorrentClients", name, "QuiProxyURL"], - meta.QuiProxyURL, - ) - : null} - {!watchClient - ? renderField( - "QbitCategoryValue", - valueFor(client, "QbitCategoryValue", "Category") ?? "", - ["TorrentClients", name, "QbitCategoryValue"], - meta.QbitCategoryValue, - ) - : null} - {!watchClient - ? renderField( - "QbitTag", - qbitTagFor(client), - ["TorrentClients", name, "QbitTag"], - meta.QbitTag, - ) - : null} - {!watchClient - ? renderField( - "QbitCrossCategory", - client.QbitCrossCategory ?? "", - ["TorrentClients", name, "QbitCrossCategory"], - meta.QbitCrossCategory, - ) - : null} - {!watchClient - ? renderField( - "QbitCrossTag", - client.QbitCrossTag ?? "", - ["TorrentClients", name, "QbitCrossTag"], - meta.QbitCrossTag, - ) - : null} - {!watchClient - ? renderField( - "UseTrackerAsTag", - client.UseTrackerAsTag ?? false, - ["TorrentClients", name, "UseTrackerAsTag"], - meta.UseTrackerAsTag, - ) - : null} - {!watchClient - ? renderField( - "Linking", - client.Linking ?? "", - ["TorrentClients", name, "Linking"], - meta.Linking, - ) - : null} - {!watchClient ? ( -
- Allow link fallback - - updateConfigValue( - ["TorrentClients", name, "AllowFallback"], - event.target.checked, - ) - } - /> -
- ) : null} - {!watchClient - ? renderField( - "LinkedFolder", - arrayFor(client, "LinkedFolder"), - ["TorrentClients", name, "LinkedFolder"], - meta.LinkedFolder, - ) - : null} - {!watchClient - ? renderField( - "LocalPath", - arrayFor(client, "LocalPath"), - ["TorrentClients", name, "LocalPath"], - meta.LocalPath, - ) - : null} - {!watchClient - ? renderField( - "RemotePath", - arrayFor(client, "RemotePath"), - ["TorrentClients", name, "RemotePath"], - meta.RemotePath, - ) - : null} - {!watchClient - ? renderField( - "AutomaticManagementPaths", - arrayFor(client, "AutomaticManagementPaths"), - ["TorrentClients", name, "AutomaticManagementPaths"], - meta.AutomaticManagementPaths, - ) - : null} - {!watchClient && advancedOpen - ? renderField( - "VerifyWebUICertificate", - client.VerifyWebUICertificate ?? true, - ["TorrentClients", name, "VerifyWebUICertificate"], - meta.VerifyWebUICertificate, - ) - : null} -
- - {!watchClient ? ( -
- qBit direct - setQbitDirect(name, event.target.checked)} - /> -
- ) : null} - - {!watchClient && directEnabled ? ( -
- {renderField( - "QbitURL", - valueFor(client, "QbitURL", "URL") ?? "", - ["TorrentClients", name, "QbitURL"], - meta.QbitURL, - )} - {renderField( - "QbitPort", - client.QbitPort ?? 0, - ["TorrentClients", name, "QbitPort"], - meta.QbitPort, - )} - {renderField( - "QbitUser", - valueFor(client, "QbitUser", "Username") ?? "", - ["TorrentClients", name, "QbitUser"], - meta.QbitUser, - )} - {renderField( - "QbitPass", - valueFor(client, "QbitPass", "Password") ?? "", - ["TorrentClients", name, "QbitPass"], - meta.QbitPass, - )} -
- ) : null} -
- ); - }) - )} -
-
- ); - }; - - /** - * Check if a tracker has user-supplied upload/auth config. Catalog defaults - * include every tracker and URL, so entry presence or default_tracker membership - * cannot mean the tracker is enabled. - */ - const isTrackerConfigured = useCallback( - (trackerName: string, trackerValue: ConfigMap): boolean => { - if (!defaultConfig || typeof defaultConfig !== "object" || Array.isArray(defaultConfig)) { - return Object.entries(trackerValue).some(([key, value]) => - trackerActivationKeys.has(key) ? hasConfiguredTrackerValue(value, undefined) : false, - ); - } - - const defaultTrackersCfg = defaultConfig.Trackers; - if ( - !defaultTrackersCfg || - typeof defaultTrackersCfg !== "object" || - Array.isArray(defaultTrackersCfg) - ) { - return Object.entries(trackerValue).some(([key, value]) => - trackerActivationKeys.has(key) ? hasConfiguredTrackerValue(value, undefined) : false, - ); - } - - const defaultTrackersMap = defaultTrackersCfg.Trackers; - if ( - !defaultTrackersMap || - typeof defaultTrackersMap !== "object" || - Array.isArray(defaultTrackersMap) - ) { - return Object.entries(trackerValue).some(([key, value]) => - trackerActivationKeys.has(key) ? hasConfiguredTrackerValue(value, undefined) : false, - ); - } - - const baselineTracker = (defaultTrackersMap as ConfigMap)[trackerName]; - const baseline = - baselineTracker && typeof baselineTracker === "object" && !Array.isArray(baselineTracker) - ? (baselineTracker as ConfigMap) - : {}; - - for (const [key, currentValue] of Object.entries(trackerValue)) { - if (!trackerActivationKeys.has(key)) { - continue; - } - if (hasConfiguredTrackerValue(currentValue, baseline[key])) { - return true; - } - } - - return false; - }, - [defaultConfig], - ); - - const trackerSelectionNames = useMemo(() => { - if ( - !configData || - !configData.Trackers || - typeof configData.Trackers !== "object" || - Array.isArray(configData.Trackers) - ) { - return [] as string[]; - } - - const trackerRoot = configData.Trackers as ConfigMap; - const rawEntries = trackerRoot.Trackers; - const entriesRoot = - rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries) - ? (rawEntries as ConfigMap) - : {}; - const allEntries = Object.entries(entriesRoot).filter( - ([, value]) => value && typeof value === "object" && !Array.isArray(value), - ) as Array<[string, ConfigMap]>; - const manualTrackerSet = new Set( - Object.entries(manualTrackerEntries) - .filter(([, enabled]) => enabled) - .map(([name]) => name), - ); - - return allEntries - .filter( - ([trackerName, trackerValue]) => - isTrackerConfigured(trackerName, trackerValue) || manualTrackerSet.has(trackerName), - ) - .map(([trackerName]) => trackerName) - .sort((left, right) => left.localeCompare(right)); - }, [configData, manualTrackerEntries, isTrackerConfigured]); - - const renderTrackerSection = (advancedOpen: boolean) => { - try { - if ( - !configData || - !configData.Trackers || - typeof configData.Trackers !== "object" || - Array.isArray(configData.Trackers) - ) { - return null; - } - - const trackerRoot = configData.Trackers as ConfigMap; - const defaultTrackers = (trackerRoot.DefaultTrackers as ConfigValue) ?? []; - const rawEntries = trackerRoot.Trackers; - const entriesRoot = - rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries) - ? (rawEntries as ConfigMap) - : {}; - - const allEntries = Object.entries(entriesRoot).filter( - ([, value]) => value && typeof value === "object" && !Array.isArray(value), - ) as Array<[string, ConfigMap]>; - const visibleTrackerSet = new Set(trackerSelectionNames); - const visibleEntries = allEntries.filter(([trackerName]) => - visibleTrackerSet.has(trackerName), - ); - - const normalizedDefaultTrackers = normalizeDefaultTrackerList(defaultTrackers); - const preferredTrackerRaw = trackerRoot.PreferredTracker; - const preferredTracker = - typeof preferredTrackerRaw === "string" ? preferredTrackerRaw.trim() : ""; - const trackerNames = trackerSelectionNames; - const normalizedTrackerNameSet = new Set(trackerNames.map((name) => name.toLowerCase())); - const selectedDefaultTrackerCount = normalizedDefaultTrackers.filter((name) => - normalizedTrackerNameSet.has(name.toLowerCase()), - ).length; - const trackerOptions = (knownTrackers.length ? knownTrackers : Object.keys(trackerSchemas)) - .filter((name) => typeof name === "string" && name.trim().length > 0) - .slice() - .sort((a, b) => a.localeCompare(b)); - const allEntrySet = new Set(allEntries.map(([name]) => name)); - const availableTrackers = trackerOptions.filter((name) => !visibleTrackerSet.has(name)); - const trackerClientOptions = [{ value: "", label: "" }, ...torrentClientOptions]; - - const trackerFallbackSchema = [ - trackerFieldMeta.LinkDirName, - trackerFieldMeta.ImageHost, - trackerFieldMeta.APIKey, - trackerFieldMeta.AnnounceURL, - trackerFieldMeta.Username, - trackerFieldMeta.Password, - trackerFieldMeta.Anon, - ]; - const trackerSchemaFor = (name: string) => { - const imageHostField = { - ...trackerFieldMeta.ImageHost, - options: trackerOptionsForImageHost(name), - }; - const torrentClientField = { - ...trackerFieldMeta.TorrentClient, - options: trackerClientOptions, - }; - const base = trackerSchemas[name] || trackerFallbackSchema; - return [ - imageHostField, - torrentClientField, - ...base.filter((meta) => meta.key !== "ImageHost" && meta.key !== "TorrentClient"), - ]; - }; - - const buildDefault = (meta: FieldMeta) => { - if (meta.type === "boolean") return false; - if (meta.type === "number") return 0; - return ""; - }; - - const buildTrackerDefaults = (schema: FieldMeta[]) => { - const defaults: ConfigMap = {}; - schema.forEach((meta) => { - defaults[meta.key] = buildDefault(meta); - }); - return defaults; - }; - - const toggleDefaultTracker = (name: string, enabled: boolean) => { - const current = normalizeDefaultTrackerList(defaultTrackers); - const next = current.filter((entry) => entry !== name); - if (enabled) { - next.push(name); - } - updateConfigValue(["Trackers", "DefaultTrackers"], next); - }; - - const updatePreferredTracker = (value: string) => { - updateConfigValue(["Trackers", "PreferredTracker"], value.trim()); - }; - - return ( -
-
{ - const target = event.currentTarget as HTMLDetailsElement; - setDefaultTrackersPanelOpen(target.open); - }} - > - - Default trackers - - {selectedDefaultTrackerCount}/{trackerNames.length} - - -
- {trackerNames.length === 0 ? ( -

Add tracker entries to select defaults.

- ) : ( -
-
- {trackerNames.map((tracker) => ( - toggleDefaultTracker(tracker, checked)} - > - {tracker} - - ))} -
-
- )} -
-
- -
- Preferred tracker data source -
-
- - -
-

- Moves the selected tracker to the top of tracker-data lookup and qBit tracker - priority when present. -

-
-
- -
-

Entries

-
- - -
-
- -
- {visibleEntries.length === 0 ? ( -

No configured entries yet.

- ) : ( - visibleEntries.map(([key, value]) => { - const schema = trackerSchemaFor(key).filter((meta): meta is FieldMeta => - Boolean(meta), - ); - return ( -
{ - const target = event.currentTarget as HTMLDetailsElement; - setSettingsTrackerPanels((prev) => ({ ...prev, [key]: target.open })); - }} - > - - {key} - - -
-
- {schema - .filter((meta) => !(meta.advanced && !advancedOpen)) - .map((meta) => - renderField( - meta.key, - value[meta.key] ?? buildDefault(meta), - ["Trackers", "Trackers", key, meta.key], - meta, - ), - )} -
-
-
- ); - }) - )} -
-
- ); - } catch (err) { - return

Unable to render tracker settings: {String(err)}

; - } - }; - - const renderImageHostingSection = () => { - if (!configData || !configData.ImageHosting || typeof configData.ImageHosting !== "object") { - return null; - } - - const imageCfg = configData.ImageHosting as ConfigMap; - const trackersRoot = - configData.Trackers && - typeof configData.Trackers === "object" && - !Array.isArray(configData.Trackers) - ? (configData.Trackers as ConfigMap) - : null; - const trackerEntries = - trackersRoot?.Trackers && - typeof trackersRoot.Trackers === "object" && - !Array.isArray(trackersRoot.Trackers) - ? (trackersRoot.Trackers as ConfigMap) - : null; - const rfTrackerCfg = - trackerEntries?.RF && - typeof trackerEntries.RF === "object" && - !Array.isArray(trackerEntries.RF) - ? (trackerEntries.RF as ConfigMap) - : null; - const hostFields = ["Host1", "Host2", "Host3", "Host4", "Host5", "Host6"]; - const requiredKeys = new Set(); - hostFields.forEach((field) => { - const selected = String(imageCfg[field] ?? "").trim(); - if (!selected) return; - const keys = imageHostKeyMap[selected]; - if (keys) { - keys.forEach((key) => requiredKeys.add(key)); - } - }); - - return ( -
-
-
Host Priority
-
- {hostFields.map((field, index) => ( - - ))} -
-
- -
-
API Keys
- {requiredKeys.size === 0 ? ( -

Select an image host to edit its API keys.

- ) : ( -
- {Array.from(requiredKeys).map((key) => - renderField(key, imageCfg[key] as ConfigValue, ["ImageHosting", key]), - )} -
- )} -
- -
-
Tracker Image Hosts
-
-
- LST Lostimg - - updateConfigValue(["ImageHosting", "LostimgEnabled"], event.target.checked) - } - /> -
- {renderField( - "LostimgAPI", - (imageCfg.LostimgAPI as ConfigValue) ?? "", - ["ImageHosting", "LostimgAPI"], - sectionFieldMeta.ImageHosting.LostimgAPI, - )} -
- RF Reelflix - - updateConfigValue( - ["Trackers", "Trackers", "RF", "ImageHost"], - event.target.checked ? "reelflix" : "", - ) - } - /> -
- {renderField( - "ImgAPI", - (rfTrackerCfg?.ImgAPI as ConfigValue) ?? "", - ["Trackers", "Trackers", "RF", "ImgAPI"], - trackerFieldMeta.ImgAPI, - )} -
-
-
- ); - }; - - return { - configData, - settingsLoading, - settingsDirty, - settingsSaved, - settingsError, - settingsSection, - settingsSections, - showAdvancedToggle, - advancedOpen, - setSettingsSection, - setSettingsAdvanced, - loadSettings, - handleSaveSettings, - renderImageHostingSection, - renderTrackerSection, - renderTorrentClientsSection, - renderMapSection, - renderField, - sectionFieldMeta: effectiveSectionFieldMeta, - updateConfigValue, - updateScreenshotConfigValue, - configuredImageHosts, - screenshotConfig, - buildSavePayload, - clearSettingsStatus, - markSettingsSaved, - setSettingsSavedMessage, - setSettingsErrorMessage, - resolveImageHostLabel, - knownTrackersLoading, - trackerSelectionNames, - }; -}; diff --git a/gui/frontend/src/hooks/useUploadImages.ts b/gui/frontend/src/hooks/useUploadImages.ts deleted file mode 100644 index a611e2f3..00000000 --- a/gui/frontend/src/hooks/useUploadImages.ts +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { useState, useCallback, useMemo, useEffect } from "react"; -import type { - ScreenshotPreviewImage, - UploadedImageLink, - UploadImageHostFailure, - ExternalIDOverrides, - ReleaseNameOverrides, -} from "../types"; -import { normalizeOverrides, normalizeReleaseOverrides } from "../utils"; - -interface UploadImagesHookProps { - path: string; - idOverrideState?: { overrides?: ExternalIDOverrides }; - releaseOverrideState?: { overrides?: ReleaseNameOverrides }; - uploadCandidates?: ScreenshotPreviewImage[]; - configuredImageHosts?: string[]; - selectedTrackers?: string[]; -} - -export const useUploadImages = ({ - path, - idOverrideState, - releaseOverrideState, - uploadCandidates = [], - configuredImageHosts = [], - selectedTrackers = [], -}: UploadImagesHookProps) => { - // State: Host & selection - const [uploadHost, setUploadHost] = useState(""); - const [uploadSelections, setUploadSelections] = useState>({}); - - // State: Upload progress & results - const [uploadImagesLoading, setUploadImagesLoading] = useState(false); - const [uploadImagesError, setUploadImagesError] = useState(""); - const [uploadImageFailures, setUploadImageFailures] = useState([]); - const [uploadedImages, setUploadedImages] = useState([]); - const [uploadedImageRecords, setUploadedImageRecords] = useState([]); - const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({ - current: 0, - total: 0, - }); - - // Build set of upload candidate paths for filtering - const uploadCandidatePaths = useMemo( - () => new Set(uploadCandidates.map((item) => item.image.Path)), - [uploadCandidates], - ); - - // Build record-by-path map for display - const uploadedRecordByPath = useMemo(() => { - const map = new Map(); - uploadedImageRecords.forEach((record) => { - if (record.ImagePath) { - const existing = map.get(record.ImagePath); - if (existing) { - const existingTime = existing.UploadedAt ? Date.parse(existing.UploadedAt) : 0; - const recordTime = record.UploadedAt ? Date.parse(record.UploadedAt) : 0; - if (recordTime > existingTime) { - map.set(record.ImagePath, record); - } - } else { - map.set(record.ImagePath, record); - } - } - }); - return map; - }, [uploadedImageRecords]); - - // Initialize host if not set and candidates exist - useEffect(() => { - if (uploadHost || configuredImageHosts.length === 0) return; - setUploadHost(configuredImageHosts[0]); - }, [configuredImageHosts, uploadHost]); - - // Initialize selections when candidates change - useEffect(() => { - if (uploadCandidates.length === 0) { - setUploadSelections({}); - return; - } - setUploadSelections((prev) => { - const next: Record = { ...prev }; - uploadCandidates.forEach((item) => { - const pathValue = item.image.Path; - if (!pathValue) return; - if (next[pathValue] === undefined) { - next[pathValue] = true; - } - }); - // Remove selections for paths no longer in candidates - Object.keys(next).forEach((key) => { - if (!uploadCandidatePaths.has(key)) { - delete next[key]; - } - }); - return next; - }); - }, [uploadCandidates, uploadCandidatePaths]); - - // Toggle selection of a single image - const toggleUploadSelection = useCallback((imagePath: string) => { - if (!imagePath) return; - setUploadSelections((prev) => ({ - ...prev, - [imagePath]: prev[imagePath] === undefined ? false : !prev[imagePath], - })); - }, []); - - // Set all selections to same state - const setAllUploadSelections = useCallback( - (value: boolean) => { - if (uploadCandidates.length === 0) return; - const next: Record = {}; - uploadCandidates.forEach((item) => { - if (item.image.Path) { - next[item.image.Path] = value; - } - }); - setUploadSelections(next); - }, - [uploadCandidates], - ); - - // Refresh uploaded images from backend - const refreshUploadedImages = useCallback(async () => { - const fetcher = globalThis.go?.guiapp?.App?.ListUploadedImages; - if (!fetcher) return; - if (!path.trim()) { - setUploadedImageRecords([]); - return; - } - try { - const records = await fetcher( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - ); - setUploadedImageRecords(records || []); - } catch (err) { - console.error("Failed to load uploaded images:", err); - } - }, [path, idOverrideState, releaseOverrideState]); - - // Upload selected images to host - const handleUploadImages = useCallback( - async (selected: ScreenshotPreviewImage[]) => { - setUploadImagesError(""); - setUploadImageFailures([]); - const uploader = globalThis.go?.guiapp?.App?.UploadImages; - if (!uploader) { - setUploadImagesError("Image uploading is unavailable in this build."); - return; - } - if (!path.trim()) { - setUploadImagesError("Please select a file or folder."); - return; - } - if (!uploadHost) { - setUploadImagesError("Select an image host to upload."); - return; - } - - if (selected.length === 0) { - setUploadImagesError("Select at least one image to upload."); - return; - } - - setUploadImagesLoading(true); - setUploadProgress({ current: 0, total: selected.length }); - try { - const result = await uploader( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - selectedTrackers, - uploadHost, - selected.map((entry) => entry.image), - ); - const links = result?.Links || []; - const failures = result?.Failures || []; - const uploadedCount = new Set(links.map((link) => link.ImagePath).filter(Boolean)).size; - setUploadedImages(links); - setUploadImageFailures(failures); - if (failures.length > 0) { - const failedHosts = Array.from( - new Set(failures.map((failure) => failure.Host || "unknown host")), - ).join(", "); - setUploadImagesError(`Image upload failed for ${failedHosts}.`); - } - if (uploadedCount !== selected.length) { - console.error("Upload count mismatch", { - expectedCount: selected.length, - uploadedCount, - }); - if (failures.length === 0) { - setUploadImagesError( - `Upload completed with an unexpected result count (expected ${selected.length}, got ${uploadedCount}).`, - ); - } - } - setUploadProgress({ - current: uploadedCount, - total: selected.length, - }); - await refreshUploadedImages(); - } catch (err) { - setUploadImagesError(String(err)); - setUploadImageFailures([]); - setUploadProgress({ current: 0, total: selected.length }); - } finally { - setUploadImagesLoading(false); - } - }, - [ - path, - idOverrideState, - releaseOverrideState, - selectedTrackers, - uploadHost, - refreshUploadedImages, - ], - ); - - // Delete a single uploaded image record - const handleDeleteUploadedImage = useCallback( - async (imagePath: string, host: string) => { - if (!imagePath || !path.trim() || !host) { - console.error("Cannot delete uploaded image: missing path, imagePath, or host"); - return; - } - - try { - await globalThis.go?.guiapp?.App?.DeleteUploadedImage(path.trim(), imagePath, host); - // Refresh existing images to reflect deletion - const refreshed = await globalThis.go?.guiapp?.App?.ListUploadCandidates( - path.trim(), - normalizeOverrides(idOverrideState?.overrides || {}), - normalizeReleaseOverrides(releaseOverrideState?.overrides || {}), - ); - - if (!refreshed || refreshed.length === 0) { - setUploadedImages((prev) => - prev.filter( - (image) => !(image.ImagePath === imagePath && (image.Host || uploadHost) === host), - ), - ); - await refreshUploadedImages(); - return; - } - - setUploadedImages((prev) => - prev.filter( - (image) => !(image.ImagePath === imagePath && (image.Host || uploadHost) === host), - ), - ); - await refreshUploadedImages(); - } catch (err) { - console.error("Failed to delete uploaded image:", err); - } - }, - [path, idOverrideState, releaseOverrideState, uploadHost, refreshUploadedImages], - ); - - // Reset upload state - const resetUploadState = useCallback(() => { - setUploadHost(""); - setUploadSelections({}); - setUploadImagesLoading(false); - setUploadImagesError(""); - setUploadImageFailures([]); - setUploadedImages([]); - setUploadedImageRecords([]); - setUploadProgress({ current: 0, total: 0 }); - }, []); - - return { - // State - uploadHost, - uploadSelections, - uploadImagesLoading, - uploadImagesError, - uploadImageFailures, - uploadedImages, - uploadedImageRecords, - uploadedRecordByPath, - uploadProgress, - - // Setters - setUploadHost, - setUploadSelections, - setUploadImagesLoading, - setUploadImagesError, - setUploadedImages, - setUploadedImageRecords, - - // Handlers & utilities - toggleUploadSelection, - setAllUploadSelections, - refreshUploadedImages, - handleUploadImages, - handleDeleteUploadedImage, - resetUploadState, - }; -}; diff --git a/gui/frontend/src/pages/bluray_candidates/index.tsx b/gui/frontend/src/pages/bluray_candidates/index.tsx deleted file mode 100644 index fcbf72ce..00000000 --- a/gui/frontend/src/pages/bluray_candidates/index.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import type { BlurayReleaseCandidate, MetadataPreview } from "../../types"; -import { handleExternalLinkClick } from "../../utils/externalLinks"; - -type Props = { - preview: MetadataPreview; - selecting: boolean; - error: string; - onSelect: (releaseID: string) => void; - setLightboxImage: (url: string) => void; - setLightboxAlt: (alt: string) => void; -}; - -const scoreLabel = (candidate: BlurayReleaseCandidate) => `${candidate.Score.toFixed(1)}/100`; - -export default function BlurayCandidatesPage(props: Props) { - const { preview, selecting, error, onSelect, setLightboxImage, setLightboxAlt } = props; - const bluray = preview.Bluray; - const candidates = bluray?.Candidates || []; - const selectedID = bluray?.SelectedReleaseID || ""; - - return ( -
-
-

Blu-ray.com

-

Release Candidates

-

Best accepted match loads by score; select another release here.

-
- - {error ?

{error}

: null} - - {!bluray ? ( -
-

No Blu-ray.com lookup data available.

-
- ) : ( - <> -
-
-
-

Best score

-

{bluray.BestScore.toFixed(1)}

-
-
-

Required score

-

{bluray.Threshold.toFixed(1)}

-
-
-

Auto-selected

-

{bluray.AutoSelected ? "Yes" : "No"}

-
-
-

Candidates

-

{candidates.length}

-
-
- {bluray.SearchURL ? ( - - Open search - - ) : null} - {!selectedID && bluray.SelectionReason ? ( -

- {bluray.SelectionReason} -

- ) : null} -
- - {candidates.length === 0 ? ( -
-

No release candidates found.

-
- ) : ( -
- {candidates.map((candidate) => { - const selected = candidate.ReleaseID === selectedID || candidate.Accepted; - const hasReleaseID = Boolean(candidate.ReleaseID); - return ( -
-
-
-

{candidate.Title || "-"}

-

- {[candidate.MovieTitle, candidate.MovieYear].filter(Boolean).join(" ")} -

-
- -
- -
-
-

Score

-

{scoreLabel(candidate)}

-
-
-

Country

-

{candidate.Country || "-"}

-
-
-

Region

-

{candidate.Region || "-"}

-
-
-

Publisher

-

{candidate.Publisher || "-"}

-
-
-

Disc

-

{candidate.Specs?.Discs?.Format || "-"}

-
-
- - {candidate.URL ? ( - - Open release - - ) : null} - -
-
-

Video

-

- {[candidate.Specs?.Video?.Codec, candidate.Specs?.Video?.Resolution] - .filter(Boolean) - .join(" ") || "-"} -

-
-
-

Audio

-

- {(candidate.Specs?.Audio || []).slice(0, 3).join("; ") || "-"} -

-
-
-

Subtitles

-

- {(candidate.Specs?.Subtitles || []).slice(0, 6).join(", ") || "-"} -

-
-
- - {candidate.MatchNotes?.length ? ( -
-

Score notes

-

- {candidate.MatchNotes.slice(0, 5).join(" | ")} -

-
- ) : null} - - {candidate.CoverImages?.length ? ( -
- {candidate.CoverImages.map((image) => ( - - ))} -
- ) : null} -
- ); - })} -
- )} - - )} -
- ); -} diff --git a/gui/frontend/src/pages/description_builder/index.test.tsx b/gui/frontend/src/pages/description_builder/index.test.tsx deleted file mode 100644 index 1102add4..00000000 --- a/gui/frontend/src/pages/description_builder/index.test.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { DescriptionBuilderPreview } from "../../types"; -import DescriptionBuilderPage from "./index"; - -describe("DescriptionBuilderPage", () => { - afterEach(() => { - cleanup(); - }); - - it("does not decode sanitized attribute entities before rendering", () => { - const builderPreview = { - SourcePath: "C:/media/Movie.mkv", - Groups: [ - { - GroupKey: "unit3d", - Trackers: ["BLU"], - Description: "", - DescriptionHTML: "", - RawDescription: "raw", - RawDescriptionHTML: ``, - HasOverride: false, - ImageHost: { Status: "", SelectedHost: "", AllowedHosts: [], Warnings: [] }, - }, - ], - } as unknown as DescriptionBuilderPreview; - - const { container } = render( - , - ); - - const image = container.querySelector(".tracker-description.rendered img"); - expect(image).toBeInstanceOf(HTMLImageElement); - expect(image?.getAttribute("onerror")).toBeNull(); - expect(image?.getAttribute("src")).toContain(`" onerror="alert(1)`); - }); - - it("renders final built content as the raw description preview", () => { - const builderPreview = { - SourcePath: "C:/media/Movie.mkv", - Groups: [ - { - GroupKey: "unit3d", - Trackers: ["HHD"], - Description: "[img]https://img.example/final.png[/img]", - DescriptionHTML: `
`, - RawDescription: "[img]https://img.example/final.png[/img]", - RawDescriptionHTML: `
`, - HasOverride: false, - ImageHost: { Status: "", SelectedHost: "", AllowedHosts: [], Warnings: [] }, - }, - ], - } as unknown as DescriptionBuilderPreview; - - const { container } = render( - , - ); - - expect(screen.queryByText("Built Description Preview")).not.toBeInTheDocument(); - expect( - screen.getByDisplayValue("[img]https://img.example/final.png[/img]"), - ).toBeInTheDocument(); - expect(container.querySelector('img[src="https://img.example/final.png"]')).toBeInstanceOf( - HTMLImageElement, - ); - }); - - it("shows build progress while descriptions are loading", () => { - const builderPreview = { - SourcePath: "C:/media/Movie.mkv", - Groups: [], - } as unknown as DescriptionBuilderPreview; - - render( - , - ); - - expect( - screen.getAllByText("Rehosting required comparison and description images...").length, - ).toBeGreaterThan(0); - }); - - it("renders each tracker favicon for multi-tracker groups", () => { - const builderPreview = { - SourcePath: "C:/media/Movie.mkv", - Groups: [ - { - GroupKey: "unit3d|aither+blutopia|global", - Trackers: ["AITHER", "BLUTOPIA"], - Description: "", - DescriptionHTML: "", - RawDescription: "raw", - RawDescriptionHTML: "", - HasOverride: false, - ImageHost: { Status: "", SelectedHost: "", AllowedHosts: [], Warnings: [] }, - }, - ], - } as unknown as DescriptionBuilderPreview; - - const { container } = render( - { - const builderPreview = { - SourcePath: "C:/media/Movie.mkv", - Groups: [ - { - GroupKey: "unit3d", - Trackers: ["HHD"], - Description: "", - DescriptionHTML: "", - RawDescription: "raw", - RawDescriptionHTML: ` -
-
- Show -
    -
    • -
    • -
    • -
    • -
  • -
-
-
`, - HasOverride: false, - ImageHost: { Status: "", SelectedHost: "", AllowedHosts: [], Warnings: [] }, - }, - ], - } as unknown as DescriptionBuilderPreview; - - const { container } = render( - , - ); - - await waitFor(() => { - expect(container.querySelectorAll(".comparison__image-container--hidden")).toHaveLength(2); - }); - - const summary = container.querySelector(".comparison__button"); - expect(summary).toBeInstanceOf(HTMLElement); - fireEvent.click(summary as HTMLElement); - fireEvent.mouseMove(window, { clientX: 900 }); - - await waitFor(() => { - const cells = Array.from( - container.querySelectorAll(".comparison__image-container"), - ); - expect(cells[0].classList.contains("comparison__image-container--hidden")).toBe(true); - expect(cells[2].classList.contains("comparison__image-container--hidden")).toBe(false); - }); - }); -}); diff --git a/gui/frontend/src/pages/description_builder/index.tsx b/gui/frontend/src/pages/description_builder/index.tsx deleted file mode 100644 index 3549eef0..00000000 --- a/gui/frontend/src/pages/description_builder/index.tsx +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -import type { Dispatch, SetStateAction } from "react"; -import RenderedDescription from "../../components/RenderedDescription"; -import { TrackerIconImage } from "../../components/ui/tracker-icon"; -import type { TrackerIconCache } from "../../hooks/useTrackerIcons"; -import { trackerIconFor } from "../../hooks/useTrackerIcons"; -import type { DescriptionBuilderPreview } from "../../types"; - -type Props = { - path: string; - builderPreview: DescriptionBuilderPreview; - builderRawByGroup: Record; - builderRenderedByGroup: Record; - builderExpandedGroups: Record; - builderLoading: boolean; - builderSaving: boolean; - builderRenderLoading: boolean; - builderRefreshing: boolean; - builderProgressMessage: string; - builderError: string; - builderSaved: string; - useFavicons?: boolean; - faviconOnly?: boolean; - trackerIconSrcByName?: TrackerIconCache; - refreshDescriptionBuilder: () => void; - setBuilderRawByGroup: Dispatch>>; - setBuilderDirtyByGroup: Dispatch>>; - setBuilderExpandedGroups: Dispatch>>; - resetBuilderDescription: (groupKey: string) => void; - renderBuilderDescription: (groupKey: string) => void; - saveBuilderDescription: (groupKey: string) => void; -}; - -const groupLabel = (groupKey: string, trackers: string[]) => { - if (trackers.length > 0) return trackers.join(", "); - if (groupKey === "unit3d") return "Unit3D"; - return groupKey || "Description"; -}; - -export default function DescriptionBuilderPage(props: Props) { - const { - path, - builderPreview, - builderRawByGroup, - builderRenderedByGroup, - builderExpandedGroups, - builderLoading, - builderSaving, - builderRenderLoading, - builderRefreshing, - builderProgressMessage, - builderError, - builderSaved, - useFavicons = true, - faviconOnly = false, - trackerIconSrcByName = {}, - refreshDescriptionBuilder, - setBuilderRawByGroup, - setBuilderDirtyByGroup, - setBuilderExpandedGroups, - resetBuilderDescription, - renderBuilderDescription, - saveBuilderDescription, - } = props; - - const groups = builderPreview.Groups || []; - - return ( -
-
-

Description Builder

-

Customize Description

-

- Edit tracker-group raw descriptions here. Tracker-specific formatting is applied from this - builder. -

-
- -
-
-

Source path

-

{path || "No path selected"}

-
- - {builderProgressMessage ? ( -

- {builderProgressMessage} -

- ) : null} -
- - {builderError ?

{builderError}

: null} - {builderSaved ?

{builderSaved}

: null} - - {builderLoading && groups.length === 0 ? ( -
-
-

Building Descriptions

-
-

- {builderProgressMessage || - "Preparing tracker-group descriptions and image-host adjustments..."} -

-
- ) : groups.length === 0 ? ( -
-

No tracker descriptions generated yet.

-
- ) : ( - groups.map((group, i) => { - const groupKey = group.GroupKey; - const reactKey = groupKey || `default-${i}`; - const seededRaw = group.RawDescription || ""; - const raw = builderRawByGroup[groupKey] ?? seededRaw; - const seededRendered = group.RawDescriptionHTML || ""; - const renderedHTML = builderRenderedByGroup[groupKey] ?? seededRendered; - const expanded = builderExpandedGroups[groupKey] ?? false; - const trackers = (group.Trackers || []).map((tracker) => tracker.trim()).filter(Boolean); - const label = groupLabel(groupKey, trackers); - const hideTrackerNames = faviconOnly && useFavicons && trackers.length > 0; - const imageHostWarnings = group.ImageHost?.Warnings || []; - - return ( -
-
-
-

- - {trackers.map((tracker) => ( - - ))} - {hideTrackerNames ? null : label} - -

-

- {group.HasOverride - ? "Saved override active for this group." - : "Using generated raw description."} -

- {group.ImageHost?.Reuploaded && group.ImageHost?.Message ? ( -

{group.ImageHost.Message}

- ) : null} - {group.ImageHost?.Status === "warning" && group.ImageHost?.Message ? ( -

- {group.ImageHost.Message} -

- ) : null} - {imageHostWarnings.map((warning, index) => { - const host = String(warning.Host || "").trim(); - const message = String(warning.Message || "").trim(); - if (!host && !message) return null; - return ( -

- {host ? `${host} failed` : "Image host warning"} - {message ? `: ${message}` : ""} -

- ); - })} -
- -
- - {expanded ? ( - <> -
- - - -
- -
-
-

Raw Description

-

- This final raw description is the upload source of truth for{" "} - {hideTrackerNames ? "this group" : label}. -

-
- `) - btnSelectPattern = regexp.MustCompile(`(?is)]*name=["']([^"']+)["'][^>]*>(.*?)`) - btnSuccessURLPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)(?:&torrentid=(\d+))?`) - btnHTMLURLAttrPattern = regexp.MustCompile(`(?is)\b(?:href|action)=["']([^"']+)["']`) - btnIMDBEpisodePattern = regexp.MustCompile(`(?i)(?:^|\bE|episode\s*)(\d{1,4})(?:\b|$)`) - // btnCountryMap maps normalized BTN country option labels and exact - // metadata-source country codes to BTN's country select values. - btnCountryMap = map[string]string{ - "se": "1", "swe": "1", "sweden": "1", - "us": "2", "usa": "2", "united states": "2", "united states of america": "2", - "ru": "3", "rus": "3", "russia": "3", "russian federation": "3", - "fi": "4", "fin": "4", "finland": "4", - "ca": "5", "can": "5", "canada": "5", - "fr": "6", "fra": "6", "france": "6", - "de": "7", "deu": "7", "germany": "7", - "cn": "8", "chn": "8", "china": "8", - "it": "9", "ita": "9", "italy": "9", - "dk": "10", "dnk": "10", "denmark": "10", - "no": "11", "nor": "11", "norway": "11", - "gb": "12", "uk": "12", "gbr": "12", "united kingdom": "12", - "ie": "13", "irl": "13", "ireland": "13", - "pl": "14", "pol": "14", "poland": "14", - "nl": "15", "nld": "15", "netherlands": "15", - "be": "16", "bel": "16", "belgium": "16", - "jp": "17", "jpn": "17", "japan": "17", - "br": "18", "bra": "18", "brazil": "18", - "ar": "19", "arg": "19", "argentina": "19", - "au": "20", "aus": "20", "australia": "20", - "nz": "21", "nzl": "21", "new zealand": "21", - "es": "22", "esp": "22", "spain": "22", - "pt": "23", "prt": "23", "portugal": "23", - "mx": "24", "mex": "24", "mexico": "24", - "sg": "25", "sgp": "25", "singapore": "25", - "za": "26", "zaf": "26", "south africa": "26", - "kr": "27", "kor": "27", "south korea": "27", - "jm": "28", "jam": "28", "jamaica": "28", - "lu": "29", "lux": "29", "luxembourg": "29", - "hk": "30", "hkg": "30", "hong kong": "30", - "bz": "31", "blz": "31", "belize": "31", - "dz": "32", "dza": "32", "algeria": "32", - "ao": "33", "ago": "33", "angola": "33", - "at": "34", "aut": "34", "austria": "34", - "yu": "35", "yug": "35", "yugoslavia": "35", - "ws": "36", "wsm": "36", "western samoa": "36", - "my": "37", "mys": "37", "malaysia": "37", - "do": "38", "dom": "38", "dominican republic": "38", - "gr": "39", "grc": "39", "greece": "39", - "gt": "40", "gtm": "40", "guatemala": "40", - "il": "41", "isr": "41", "israel": "41", - "pk": "42", "pak": "42", "pakistan": "42", - "cz": "43", "cze": "43", "czech republic": "43", "czechia": "43", - "rs": "44", "srb": "44", "serbia": "44", - "sc": "45", "syc": "45", "seychelles": "45", - "tw": "46", "twn": "46", "taiwan": "46", - "pr": "47", "pri": "47", "puerto rico": "47", - "cl": "48", "chl": "48", "chile": "48", - "cu": "49", "cub": "49", "cuba": "49", - "cg": "50", "cog": "50", "congo": "50", - "af": "51", "afg": "51", "afghanistan": "51", - "tr": "52", "tur": "52", "turkey": "52", - "uz": "53", "uzb": "53", "uzbekistan": "53", - "ch": "54", "che": "54", "switzerland": "54", - "ki": "55", "kir": "55", "kiribati": "55", - "ph": "56", "phl": "56", "philippines": "56", - "bf": "57", "bfa": "57", "burkina faso": "57", - "ng": "58", "nga": "58", "nigeria": "58", - "is": "59", "isl": "59", "iceland": "59", - "nr": "60", "nru": "60", "nauru": "60", - "si": "61", "svn": "61", "slovenia": "61", - "al": "62", "alb": "62", "albania": "62", - "tm": "63", "tkm": "63", "turkmenistan": "63", - "ba": "64", "bih": "64", "bosnia herzegovina": "64", "bosnia and herzegovina": "64", - "ad": "65", "and": "65", "andorra": "65", - "lt": "66", "ltu": "66", "lithuania": "66", - "in": "67", "ind": "67", "india": "67", - "an": "68", "ant": "68", "netherlands antilles": "68", - "ua": "69", "ukr": "69", "ukraine": "69", - "ve": "70", "ven": "70", "venezuela": "70", - "hu": "71", "hun": "71", "hungary": "71", - "ro": "72", "rou": "72", "romania": "72", - "vu": "73", "vut": "73", "vanuatu": "73", - "vn": "74", "vnm": "74", "vietnam": "74", - "tt": "75", "tto": "75", "trinidad": "75", "trinidad and tobago": "75", - "hn": "76", "hnd": "76", "honduras": "76", - "kg": "77", "kgz": "77", "kyrgyzstan": "77", - "ec": "78", "ecu": "78", "ecuador": "78", - "bs": "79", "bhs": "79", "bahamas": "79", - "pe": "80", "per": "80", "peru": "80", - "kh": "81", "khm": "81", "cambodia": "81", - "bb": "82", "brb": "82", "barbados": "82", - "bd": "83", "bgd": "83", "bangladesh": "83", - "la": "84", "lao": "84", "laos": "84", - "uy": "85", "ury": "85", "uruguay": "85", - "ag": "86", "atg": "86", "antigua barbuda": "86", "antigua and barbuda": "86", - "py": "87", "pry": "87", "paraguay": "87", - "su": "88", "sun": "88", "soviet": "88", "soviet union": "88", "ussr": "88", "union of soviet socialist repu": "88", - "th": "89", "tha": "89", "thailand": "89", - "sn": "90", "sen": "90", "senegal": "90", - "tg": "91", "tgo": "91", "togo": "91", - "kp": "92", "prk": "92", "north korea": "92", - "hr": "93", "hrv": "93", "croatia": "93", - "ee": "94", "est": "94", "estonia": "94", - "co": "95", "col": "95", "colombia": "95", - "lb": "96", "lbn": "96", "lebanon": "96", - "lv": "97", "lva": "97", "latvia": "97", - "cr": "98", "cri": "98", "costa rica": "98", - "eg": "99", "egy": "99", "egypt": "99", - "bg": "100", "bgr": "100", "bulgaria": "100", - "isle de muerte": "101", - "fj": "102", "fji": "102", "fiji": "102", - "mk": "103", "mkd": "103", "macedonia": "103", - "kw": "104", "kwt": "104", "kuwait": "104", - "lk": "105", "lka": "105", "sri lanka": "105", - "ir": "106", "irn": "106", "iran": "106", - "arab league": "107", - "sa": "108", "sau": "108", "saudi arabia": "108", - "scotland": "109", - "sk": "110", "svk": "110", "slovakia": "110", - "id": "111", "idn": "111", "indonesia": "111", - "wales": "112", - "bn": "113", "brn": "113", "brunei": "113", - } -) - -// ErrSubmitted2FARejected marks a BTN failure after a submitted manual 2FA code -// reached the tracker and was rejected. -var ErrSubmitted2FARejected = errors.New("trackers: BTN submitted 2FA rejected") - -var ( - errBTNCookiesMissing = errors.New("trackers: BTN cookies not configured") - errBTNSessionConfirmedInvalid = errors.New("trackers: BTN stored session confirmed invalid") -) - -type uploadContext struct { - baseURL string - uploadURL string - apiToken string - apiURL string - client *http.Client -} - -// btnNameNormalizationRule applies one ordered rewrite to the BTN scene name. -type btnNameNormalizationRule struct { - pattern *regexp.Regexp - replacement string -} - -// btnNameNormalizationRules normalizes release-name tokens after whitespace and -// DD+ cleanup. The order is significant: Atmos-specific compaction must happen -// before generic audio channel joins, and dot collapse must run after character -// replacement. -var btnNameNormalizationRules = []btnNameNormalizationRule{ - {pattern: regexp.MustCompile(`(?i)\.DDP\.(\d+(?:\.\d+)?)\.Atmos`), replacement: `.DDPA$1`}, - {pattern: regexp.MustCompile(`(?i)\.TrueHD\.(\d+(?:\.\d+)?)\.Atmos`), replacement: `.TrueHDA$1`}, - {pattern: regexp.MustCompile(`\.DDP\.(\d)`), replacement: `.DDP$1`}, - {pattern: regexp.MustCompile(`\.DD\.(\d)`), replacement: `.DD$1`}, - {pattern: regexp.MustCompile(`\.AC3\.(\d)`), replacement: `.AC3$1`}, - {pattern: regexp.MustCompile(`\.DTS\.(\d)`), replacement: `.DTS$1`}, - {pattern: regexp.MustCompile(`\.AAC\.(\d)`), replacement: `.AAC$1`}, - {pattern: regexp.MustCompile(`\.FLAC\.(\d)`), replacement: `.FLAC$1`}, - {pattern: regexp.MustCompile(`(?i)\.TrueHD\.(\d)`), replacement: `.TrueHD$1`}, - {pattern: regexp.MustCompile(`(?i)\.PCM\.(\d)`), replacement: `.PCM$1`}, - {pattern: regexp.MustCompile(`(?i)\.LPCM\.(\d)`), replacement: `.LPCM$1`}, - {pattern: regexp.MustCompile(`[^a-zA-Z0-9.\-]`), replacement: `.`}, - {pattern: regexp.MustCompile(`\.{2,}`), replacement: `.`}, -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - if err := validateBTNRequest(req); err != nil { - return api.UploadSummary{}, err - } - if message, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { - if req.Logger != nil { - req.Logger.Warnf("trackers: BTN %s", message) - } - return api.UploadSummary{}, err - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.UploadSummary{}, err - } - - uploadCtx, err := newUploadContext(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - client, err := ensureBTNUploadSession(ctx, req.TrackerConfig, req.AppConfig.MainSettings.DBPath, uploadCtx) - if err != nil { - return api.UploadSummary{}, err - } - uploadCtx.client = client - - if err := checkBTNSeasonPackReservation(ctx, uploadCtx, req); err != nil { - return api.UploadSummary{}, err - } - - data, err := prepareUploadData(ctx, req, uploadCtx) - if err != nil { - return api.UploadSummary{}, err - } - - files := resolveBTNUploadFiles(req.Meta, torrentPath) - body, contentType, err := commonhttp.BuildMultipartPayload(data, files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadCtx.uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: BTN request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - - resp, err := uploadCtx.client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: BTN upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - trackerTorrentPath, err := resolveTrackerTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath, "BTN") - if err != nil { - return api.UploadSummary{}, err - } - - groupID, torrentID, matched := btnUploadIDsFromText(finalURL) - torrentDownloaded := false - if !matched { - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 400, 2048) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: BTN read upload response: %w", err) - } - intermediate, handled, err := resolveBTNUploadIntermediatePage(ctx, uploadCtx.client, uploadCtx.baseURL, finalURL, responseBody) - if handled { - if err != nil { - if req.Logger != nil { - req.Logger.Warnf("trackers: BTN intermediate upload page fallback to API search: %s", commonhttp.RedactErrorDetail(err.Error())) - } - groupID = intermediate.groupID - selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) - if err != nil { - return api.UploadSummary{}, err - } - torrentID = selectedID - if selectedGroupID != "" { - groupID = selectedGroupID - } - torrentDownloaded = true - } else { - groupID = intermediate.groupID - torrentID = intermediate.torrentID - } - } else { - groupID, torrentID, matched = btnUploadIDsFromText(string(responseBody)) - } - if !matched && groupID == "" && torrentID == "" { - failurePath, _ := commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "BTN", "upload-failure", responsePreview, ".html") - if failurePath != "" { - return api.UploadSummary{}, fmt.Errorf("%w failure=%s", commonhttp.UploadHTTPErrorWithURL("BTN", resp.StatusCode, finalURL, responsePreview), failurePath) - } - return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("BTN", resp.StatusCode, finalURL, responsePreview) - } - } - torrentURL := buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) - - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" && torrentID != "" && !torrentDownloaded { - if err := writeBTNTorrentArtifact(torrentPath, trackerTorrentPath, announceURL, torrentURL); err != nil { - return api.UploadSummary{}, err - } - torrentDownloaded = true - } - - if torrentID != "" && !torrentDownloaded { - if err := downloadTrackerTorrent(ctx, uploadCtx.client, uploadCtx.baseURL, torrentID, trackerTorrentPath); err != nil { - if req.Logger != nil { - req.Logger.Warnf("trackers: BTN torrent download fallback to API search: %s", commonhttp.RedactErrorDetail(err.Error())) - } - selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) - if err != nil { - return api.UploadSummary{}, err - } - torrentID = selectedID - if selectedGroupID != "" { - groupID = selectedGroupID - } - torrentURL = buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) - } - } else if torrentID == "" { - selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) - if err != nil { - return api.UploadSummary{}, err - } - torrentID = selectedID - if selectedGroupID != "" { - groupID = selectedGroupID - } - torrentURL = buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) - } - - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "BTN", - TorrentID: torrentID, - TorrentURL: torrentURL, - DownloadURL: torrentURL, - TorrentPath: trackerTorrentPath, - }}, - }, nil -} - -// buildUploadDryRun returns a BTN preview entry with the exact payload that -// would be submitted locally. TV payloads that would serialize missing -// canonical season or episode metadata as zero are returned as blocked so the -// operator sees the remediation before upload. -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - if err := validateBTNRequest(req); err != nil { - return api.TrackerDryRunEntry{}, err - } - - uploadCtx, err := newUploadContext(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - if err := validateBTNDryRunUploadAuth(ctx, req, uploadCtx); err != nil { - return api.TrackerDryRunEntry{}, err - } - - autofillPayload, uploadType := buildBTNAutofillPayload(req.Meta) - debugSections := make([]api.TrackerDryRunDebugSection, 0, 2) - if req.Meta.Options.Debug { - debugSections = append(debugSections, api.TrackerDryRunDebugSection{ - Title: "BTN autofill request", - Endpoint: uploadCtx.uploadURL, - Payload: urlValuesToPayloadMap(autofillPayload), - }) - } - - payload := map[string]string{ - "submit": "true", - "type": uploadType, - "scenename": resolveUploadName(req.Meta), - "origin": resolveOrigin(req.Meta), - "release_desc": resolveBTNReleaseDesc(req.Meta), - "tvdb": "autofilled", - } - if resolveFastTorrent(req.TrackerConfig) { - payload["fasttorrent"] = "on" - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - - message := "dry-run payload generated" - status := "ready" - if metadataMessage, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { - message += "; " + metadataMessage - status = "blocked" - } - if req.Meta.Options.Debug && status == "ready" { - client, err := ensureBTNUploadSession(ctx, req.TrackerConfig, req.AppConfig.MainSettings.DBPath, uploadCtx) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - uploadCtx.client = client - fields, err := requestBTNAutofillFields(ctx, uploadCtx, autofillPayload, uploadType) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - payload, err = buildBTNUploadPayload(req, fields) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - message += "; BTN autofill debug completed" - debugSections = append(debugSections, api.TrackerDryRunDebugSection{ - Title: "BTN final upload payload after autofill", - Endpoint: uploadCtx.uploadURL, - Payload: payload, - Files: resolveBTNDryRunFiles(req.Meta, torrentPath), - }) - } - - return api.TrackerDryRunEntry{ - Tracker: "BTN", - Status: status, - Message: message, - ReleaseName: resolveUploadName(req.Meta), - DescriptionGroup: "btn", - Description: payload["release_desc"], - Endpoint: uploadCtx.uploadURL, - Payload: payload, - Files: resolveBTNDryRunFiles(req.Meta, torrentPath), - DebugSections: debugSections, - }, nil -} - -// validateBTNDryRunUploadAuth checks auth prerequisites needed before an upload -// can authenticate. Stored cookies must pass the same upload-page session gate -// as live upload; credential fallback remains local-only and does not log in or -// persist cookies during dry-run. -func validateBTNDryRunUploadAuth(ctx context.Context, req trackers.UploadRequest, uploadCtx uploadContext) error { - values, cookieErr := loadBTNCookies(ctx, req.AppConfig.MainSettings.DBPath) - if cookieErr == nil && len(values) > 0 { - client, err := newBTNClientWithCookies(uploadCtx.baseURL, values) - if err != nil { - return err - } - if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err == nil { - return nil - } else if !errors.Is(err, errBTNSessionConfirmedInvalid) { - return err - } else if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { - return err - } - } - if cookieErr != nil && !errors.Is(cookieErr, errBTNCookiesMissing) { - return cookieErr - } - if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { - return errors.New("trackers: BTN cookie invalid/missing and username/password not configured") - } - return nil -} - -func newUploadContext(ctx context.Context, req trackers.UploadRequest) (uploadContext, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return uploadContext{}, fmt.Errorf("trackers: BTN create cookie jar: %w", err) - } - client := &http.Client{Timeout: 45 * time.Second, Jar: jar} - baseURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if baseURL == "" { - baseURL = btnDefaultBaseURL - } - uploadCtx := uploadContext{ - baseURL: baseURL, - uploadURL: baseURL + btnUploadPath, - apiToken: config.ResolveBTNAPIToken(req.AppConfig), - apiURL: resolveBTNAPIURL(req.TrackerConfig), - client: client, - } - loadBTNCookiesIntoJar(ctx, client, req.AppConfig.MainSettings.DBPath, baseURL) - return uploadCtx, nil -} - -// ensureBTNUploadSession validates imported BTN cookies before credential login. -// Credential login cookies are persisted only after the refreshed client reaches -// the upload page, so failed or incomplete logins do not replace stored auth. -func ensureBTNUploadSession(ctx context.Context, cfg config.TrackerConfig, dbPath string, uploadCtx uploadContext) (*http.Client, error) { - values, cookieErr := loadBTNCookies(ctx, dbPath) - if cookieErr == nil && len(values) > 0 { - if client, err := newBTNClientWithCookies(uploadCtx.baseURL, values); err == nil { - if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err == nil { - return client, nil - } else if !errors.Is(err, errBTNSessionConfirmedInvalid) { - return nil, err - } - } - } - if cookieErr != nil && !errors.Is(cookieErr, errBTNCookiesMissing) { - return nil, cookieErr - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: BTN cookie invalid/missing and username/password not configured") - } - client, err := loginBTNSession(ctx, cfg, uploadCtx.baseURL, api.TrackerAuthLoginRequest{}) - if err != nil { - return nil, err - } - if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err != nil { - return nil, err - } - if err := persistBTNCookies(ctx, dbPath, uploadCtx.baseURL, client.Jar); err != nil { - return nil, fmt.Errorf("trackers: BTN persist cookies after successful login: %w", err) - } - return client, nil -} - -// ResolveSessionForTrackerAuth validates BTN stored cookies or logs in with -// configured credentials. Credential login must produce reusable cookies before -// refreshed cookies are persisted. -func ResolveSessionForTrackerAuth(ctx context.Context, cfg config.TrackerConfig, dbPath string) error { - return ResolveSessionForTrackerAuthLogin(ctx, cfg, dbPath, api.TrackerAuthLoginRequest{}) -} - -// ResolveSessionForTrackerAuthLogin validates BTN stored cookies or logs in -// with configured credentials. Refreshed cookies are persisted only after the -// upload page confirms the session. Manual login.Code is preferred over -// configured TOTP. Missing 2FA input preserves existing cookies; a rejected -// submitted code returns [ErrSubmitted2FARejected] before persistence. -func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { - baseURL := strings.TrimRight(strings.TrimSpace(cfg.URL), "/") - if baseURL == "" { - baseURL = btnDefaultBaseURL - } - err := validateBTNStoredCookies(ctx, baseURL, dbPath) - if err == nil { - return nil - } - if !errors.Is(err, errBTNCookiesMissing) && !errors.Is(err, errBTNSessionConfirmedInvalid) { - return err - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - if errors.Is(err, errBTNSessionConfirmedInvalid) { - return err - } - return errors.New("trackers: BTN cookie invalid/missing and username/password not configured") - } - client, err := loginBTNSession(ctx, cfg, baseURL, login) - if err != nil { - return err - } - if err := validateBTNClientSession(ctx, client, baseURL); err != nil { - if strings.TrimSpace(login.Code) != "" && errors.Is(err, errBTNSessionConfirmedInvalid) { - return fmt.Errorf("trackers: BTN submitted 2FA validation failed: %w", ErrSubmitted2FARejected) - } - return err - } - if err := persistBTNCookies(ctx, dbPath, baseURL, client.Jar); err != nil { - return fmt.Errorf("trackers: BTN persist cookies after successful login: %w", err) - } - return nil -} - -// validateBTNStoredCookies checks persisted BTN cookies against the upload page. -// Confirmed logged-out evidence is returned distinctly so tracker auth can delete -// stale cookies; storage/decrypt failures and ambiguous remote/parser failures -// preserve stored cookies and block credential login. -func validateBTNStoredCookies(ctx context.Context, baseURL string, dbPath string) error { - values, err := loadBTNCookies(ctx, dbPath) - if err != nil { - return err - } - if len(values) == 0 { - return errBTNCookiesMissing - } - client, err := newBTNClientWithCookies(baseURL, values) - if err != nil { - return err - } - return validateBTNClientSession(ctx, client, baseURL) -} - -// loginBTNSession performs the credential login step and leaves cookie -// persistence to callers after they validate the resulting upload session. -func loginBTNSession(ctx context.Context, cfg config.TrackerConfig, baseURL string, login api.TrackerAuthLoginRequest) (*http.Client, error) { - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: BTN requires username/password") - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("trackers: BTN create login cookie jar: %w", err) - } - client := &http.Client{Timeout: 45 * time.Second, Jar: jar} - values := url.Values{} - values.Set("username", strings.TrimSpace(cfg.Username)) - values.Set("password", strings.TrimSpace(cfg.Password)) - values.Set("keeplogged", "1") - if code, err := resolveBTN2FACode(cfg, login); err == nil && code != "" { - values.Set("codenumber", code) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+btnLoginPath, strings.NewReader(values.Encode())) - if err != nil { - return nil, fmt.Errorf("trackers: BTN login request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "upbrr") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("trackers: BTN login request: %w", err) - } - defer resp.Body.Close() - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - if readErr != nil { - return nil, fmt.Errorf("trackers: BTN read login response: %w", readErr) - } - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - if strings.TrimSpace(login.Code) != "" && resp.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("trackers: BTN login failed status=%d: %w", resp.StatusCode, ErrSubmitted2FARejected) - } - return nil, fmt.Errorf("trackers: BTN login failed status=%d", resp.StatusCode) - } - bodyText := string(body) - if btnLoginNeeds2FA(bodyText) { - if strings.TrimSpace(login.Code) != "" { - return nil, fmt.Errorf("trackers: BTN login failed: %w", ErrSubmitted2FARejected) - } - if _, err := resolveBTN2FACode(config.TrackerConfig{OTPURI: cfg.OTPURI}, api.TrackerAuthLoginRequest{}); err != nil { - return nil, fmt.Errorf("trackers: BTN 2FA required: %w", err) - } - } - if btnLoginFailed(bodyText) { - if strings.TrimSpace(login.Code) != "" { - return nil, fmt.Errorf("trackers: BTN login failed: %w", ErrSubmitted2FARejected) - } - return nil, errors.New("trackers: BTN login failed") - } - return client, nil -} - -// validateBTNClientSession confirms the client can reach BTN's upload page. -// It treats explicit login redirects and logged-out markers as invalid session -// evidence while keeping layout misses and upstream failures transient. -func validateBTNClientSession(ctx context.Context, client *http.Client, baseURL string) error { - if client == nil { - return errors.New("trackers: BTN session client missing") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+btnUploadPath, nil) - if err != nil { - return fmt.Errorf("trackers: BTN upload auth request build: %w", err) - } - req.Header.Set("User-Agent", "upbrr") - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("trackers: BTN upload auth request: %w", err) - } - defer resp.Body.Close() - finalPath := "" - if resp.Request != nil && resp.Request.URL != nil { - finalPath = strings.ToLower(resp.Request.URL.EscapedPath()) - } - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - if readErr != nil { - return fmt.Errorf("trackers: BTN read upload auth response: %w", readErr) - } - bodyText := string(body) - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || strings.Contains(finalPath, "login") || btnLoggedOutPage(bodyText) { - return fmt.Errorf("%w: login required", errBTNSessionConfirmedInvalid) - } - if resp.StatusCode >= 500 { - return fmt.Errorf("trackers: BTN upload auth unavailable status=%d", resp.StatusCode) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("trackers: BTN upload auth failed status=%d", resp.StatusCode) - } - if !btnLooksLikeUploadPage(bodyText) { - return errors.New("trackers: BTN upload auth page validation failed") - } - return nil -} - -func prepareUploadData(ctx context.Context, req trackers.UploadRequest, uploadCtx uploadContext) (map[string]string, error) { - if _, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { - return nil, err - } - - autofillPayload, uploadType := buildBTNAutofillPayload(req.Meta) - fields, err := requestBTNAutofillFields(ctx, uploadCtx, autofillPayload, uploadType) - if err != nil { - return nil, err - } - return buildBTNUploadPayload(req, fields) -} - -// buildBTNAutofillPayload returns the form fields for BTN's first upload.php -// POST. TVDB-backed uploads submit the series id plus season or episode token; -// scene-name autofill is used only when no TVDB series id is available. -func buildBTNAutofillPayload(meta api.PreparedMetadata) (url.Values, string) { - autofillPayload := url.Values{} - uploadType := resolveUploadType(meta) - season, episode := resolveBTNTVSeasonEpisode(meta) - autofillPayload.Set("type", uploadType) - autofillPayload.Set("tvdb", "Get Info") - - if meta.ExternalMetadata.TVDB != nil && meta.ExternalMetadata.TVDB.TVDBID > 0 { - autofillPayload.Set("scene_yesno", "No") - autofillPayload.Set("auto_series", strconv.Itoa(meta.ExternalMetadata.TVDB.TVDBID)) - - if uploadType == "Episode" { - autofillPayload.Set("auto_title", fmt.Sprintf("S%02dE%02d", season, episode)) - } else { - autofillPayload.Set("auto_season", fmt.Sprintf("S%02d", season)) - } - } else { - autofillPayload.Set("scene_yesno", "Yes") - autofillPayload.Set("autofill", resolveUploadName(meta)) - } - - return autofillPayload, uploadType -} - -// requestBTNAutofillFields performs BTN's autofill POST and extracts the form -// values returned for the final upload payload. A validation failure means BTN -// did not return enough series/title data for the requested upload type. -func requestBTNAutofillFields(ctx context.Context, uploadCtx uploadContext, autofillPayload url.Values, uploadType string) (map[string]string, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadCtx.uploadURL, strings.NewReader(autofillPayload.Encode())) - if err != nil { - return nil, fmt.Errorf("trackers: BTN autofill request build: %w", err) - } - httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - httpReq.Header.Set("User-Agent", "upbrr") - - resp, err := uploadCtx.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("trackers: BTN autofill request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("trackers: BTN autofill failed status=%d", resp.StatusCode) - } - htmlPayload, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - if err != nil { - return nil, fmt.Errorf("trackers: BTN read autofill response: %w", err) - } - fields := extractAutofillFields(string(htmlPayload)) - if !validateAutofill(fields, uploadType) { - return nil, errors.New("trackers: BTN autofill validation failed") - } - return fields, nil -} - -// buildBTNUploadPayload merges BTN autofill fields with local metadata for the -// final upload form. BTN text fields such as album_desc are retained, release_desc -// is populated only from MediaInfo text, and local MediaInfo-derived dropdown -// mappings win when both sources provide a BTN-supported value. -func buildBTNUploadPayload(req trackers.UploadRequest, fields map[string]string) (map[string]string, error) { - format := mapContainer(req.Meta, fields) - bitrate := mapCodec(req.Meta, fields) - media := mapSource(req.Meta, fields) - if format == "" || bitrate == "" || media == "" { - return nil, fmt.Errorf("trackers: BTN dropdown mapping failed format=%q bitrate=%q media=%q", format, bitrate, media) - } - - title := metautil.FirstNonEmptyTrimmed(fields["title"]) - if resolveUploadType(req.Meta) == "Season" && title != "" { - isNumeric := true - for _, r := range title { - if r < '0' || r > '9' { - isNumeric = false - break - } - } - if isNumeric { - title = "Season " + title - } - } - - resolution := mapResolution(req.Meta, fields) - logBTNAutofillMismatch(req.Logger, "format", format, fields["format"]) - logBTNAutofillMismatch(req.Logger, "bitrate", bitrate, fields["bitrate"]) - logBTNAutofillMismatch(req.Logger, "media", media, fields["media"]) - logBTNAutofillMismatch(req.Logger, "resolution", resolution, fields["resolution"]) - payload := map[string]string{ - "submit": "true", - "type": resolveUploadType(req.Meta), - "scenename": applyBTNNameMapping(resolveUploadName(req.Meta), bitrate, media), - "seriesid": metautil.FirstNonEmptyTrimmed(fields["seriesid"]), - "artist": metautil.FirstNonEmptyTrimmed(fields["artist"]), - "title": title, - "actors": metautil.FirstNonEmptyTrimmed(fields["actors"]), - "origin": resolveOrigin(req.Meta), - "year": metautil.FirstNonEmptyTrimmed(fields["year"]), - "tags": resolveBTNTags(req.Meta, fields), - "image": resolveBTNImage(req.Meta, fields), - "album_desc": buildAlbumDesc(req.Meta, fields), - "format": format, - "bitrate": bitrate, - "media": media, - "resolution": resolution, - "release_desc": resolveBTNReleaseDesc(req.Meta), - "tvdb": "autofilled", - } - if resolveFastTorrent(req.TrackerConfig) { - payload["fasttorrent"] = "on" - } - if language := resolveBTNOriginalLanguage(req.Meta); language != "" && !isBTNEnglishLanguage(language) { - payload["foreign"] = "on" - if countryID := resolveCountryID(req.Meta); countryID != "" { - payload["country"] = countryID - } - } - clean := make(map[string]string, len(payload)) - for key, value := range payload { - if key != "release_desc" && strings.TrimSpace(value) == "" { - continue - } - clean[key] = value - } - return clean, nil -} - -// resolveBTNReleaseDesc returns the MediaInfo text BTN expects in release_desc. -// Description overrides are intentionally ignored for this field. -func resolveBTNReleaseDesc(meta api.PreparedMetadata) string { - return strings.TrimSpace(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath)) -} - -// urlValuesToPayloadMap flattens form values for debug display; BTN autofill -// fields are single-valued, so later values are intentionally ignored. -func urlValuesToPayloadMap(values url.Values) map[string]string { - out := make(map[string]string, len(values)) - for key, value := range values { - if len(value) == 0 { - continue - } - out[key] = value[0] - } - return out -} - -// logBTNAutofillMismatch records when BTN autofill selected a different -// dropdown value than local metadata. The upload still uses metadata because -// autofill runs before MediaInfo or final upload fields are submitted. -func logBTNAutofillMismatch(logger api.Logger, field string, metadataValue string, autofillValue string) { - if logger == nil { - return - } - field = strings.TrimSpace(field) - metadataValue = strings.TrimSpace(metadataValue) - autofillValue = strings.TrimSpace(autofillValue) - if field == "" || metadataValue == "" || autofillValue == "" || metadataValue == autofillValue { - return - } - logger.Infof("trackers: BTN autofill %s mismatch metadata_%s=%q autofill_%s=%q decision=metadata", field, field, metadataValue, field, autofillValue) -} - -// resolveBTNTags keeps BTN autofill genres when present and otherwise maps -// TVDB then IMDb genres to BTN-supported tag labels. -func resolveBTNTags(meta api.PreparedMetadata, fields map[string]string) string { - if tags := strings.TrimSpace(fields["tags"]); tags != "" { - return tags - } - if meta.ExternalMetadata.TVDB != nil { - if tags := mapBTNGenres(meta.ExternalMetadata.TVDB.Genres); tags != "" { - return tags - } - } - if meta.ExternalMetadata.IMDB != nil { - return mapBTNGenres(meta.ExternalMetadata.IMDB.Genres) - } - return "" -} - -// resolveBTNImage keeps BTN autofill poster data when present. Empty autofill -// falls back through TVDB, IMDb, TVmaze, then TMDB poster metadata. -func resolveBTNImage(meta api.PreparedMetadata, fields map[string]string) string { - if image := strings.TrimSpace(fields["image"]); image != "" { - return image - } - if meta.ExternalMetadata.TVDB != nil { - if poster := strings.TrimSpace(meta.ExternalMetadata.TVDB.Poster); poster != "" { - return poster - } - } - if meta.ExternalMetadata.IMDB != nil { - if poster := strings.TrimSpace(meta.ExternalMetadata.IMDB.Cover); poster != "" { - return poster - } - } - if meta.ExternalMetadata.TVmaze != nil { - if poster := strings.TrimSpace(meta.ExternalMetadata.TVmaze.Poster); poster != "" { - return poster - } - if poster := strings.TrimSpace(meta.ExternalMetadata.TVmaze.PosterMedium); poster != "" { - return poster - } - } - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Poster) - } - return "" -} - -// mapBTNGenres converts provider genre text to comma-separated BTN genre tags. -// Unrecognized genres are omitted instead of being submitted as free-form tags. -func mapBTNGenres(genres string) string { - normalized := normalizeBTNGenreText(genres) - if normalized == "" { - return "" - } - type genreAlias struct { - label string - aliases []string - } - allowed := []genreAlias{ - {label: "Action", aliases: []string{"action"}}, - {label: "Adventure", aliases: []string{"adventure"}}, - {label: "Animation", aliases: []string{"animation"}}, - {label: "Anime", aliases: []string{"anime"}}, - {label: "Awards Show", aliases: []string{"awards show"}}, - {label: "Children", aliases: []string{"children", "kids"}}, - {label: "Comedy", aliases: []string{"comedy"}}, - {label: "Crime", aliases: []string{"crime"}}, - {label: "Documentary", aliases: []string{"documentary"}}, - {label: "Drama", aliases: []string{"drama"}}, - {label: "Family", aliases: []string{"family"}}, - {label: "Fantasy", aliases: []string{"fantasy"}}, - {label: "Food", aliases: []string{"food"}}, - {label: "Game Show", aliases: []string{"game show"}}, - {label: "History", aliases: []string{"history"}}, - {label: "Home and Garden", aliases: []string{"home and garden", "home garden"}}, - {label: "Horror", aliases: []string{"horror"}}, - {label: "Indie", aliases: []string{"indie"}}, - {label: "Martial Arts", aliases: []string{"martial arts"}}, - {label: "Mini-Series", aliases: []string{"mini series", "miniseries"}}, - {label: "Musical", aliases: []string{"musical", "music"}}, - {label: "Mystery", aliases: []string{"mystery"}}, - {label: "News", aliases: []string{"news"}}, - {label: "Podcast", aliases: []string{"podcast"}}, - {label: "Reality", aliases: []string{"reality"}}, - {label: "Romance", aliases: []string{"romance"}}, - {label: "Science Fiction", aliases: []string{"science fiction", "sci fi", "scifi"}}, - {label: "Soap", aliases: []string{"soap"}}, - {label: "Sport", aliases: []string{"sport", "sports"}}, - {label: "Suspense", aliases: []string{"suspense"}}, - {label: "Talk Show", aliases: []string{"talk show"}}, - {label: "Thriller", aliases: []string{"thriller"}}, - {label: "Travel", aliases: []string{"travel"}}, - {label: "War", aliases: []string{"war"}}, - {label: "Western", aliases: []string{"western"}}, - } - tags := make([]string, 0, len(allowed)) - for _, genre := range allowed { - for _, alias := range genre.aliases { - if normalizedBTNGenreContains(normalized, alias) { - tags = append(tags, genre.label) - break - } - } - } - return strings.Join(tags, ", ") -} - -// normalizeBTNGenreText lowercases and strips common separators so source -// genre text and local aliases can be compared consistently. -func normalizeBTNGenreText(value string) string { - replacer := strings.NewReplacer( - "&", " and ", - "/", " ", - ";", " ", - ":", " ", - ".", " ", - ",", " ", - "-", " ", - "_", " ", - "(", " ", - ")", " ", - ) - return strings.Join(strings.Fields(replacer.Replace(strings.ToLower(strings.TrimSpace(value)))), " ") -} - -// normalizedBTNGenreContains reports whether a normalized genre list contains -// an alias as a complete token sequence. -func normalizedBTNGenreContains(normalized string, alias string) bool { - alias = normalizeBTNGenreText(alias) - return strings.Contains(" "+normalized+" ", " "+alias+" ") -} - -// extractAutofillFields reads BTN's autofilled upload form into the field map -// consumed by final payload construction. Input values, selected dropdown -// values, and the album_desc textarea are normalized to lower-case field names. -func extractAutofillFields(htmlRaw string) map[string]string { - fields := map[string]string{} - for _, match := range btnInputPattern.FindAllStringSubmatch(htmlRaw, -1) { - if len(match) < 3 { - continue - } - fields[strings.ToLower(strings.TrimSpace(match[1]))] = html.UnescapeString(strings.TrimSpace(match[2])) - } - if match := btnTextAreaPattern.FindStringSubmatch(htmlRaw); len(match) > 1 { - fields["album_desc"] = html.UnescapeString(strings.TrimSpace(stripHTML(match[1]))) - } - for _, selectMatch := range btnSelectPattern.FindAllStringSubmatch(htmlRaw, -1) { - if len(selectMatch) < 3 { - continue - } - name := strings.ToLower(strings.TrimSpace(selectMatch[1])) - body := selectMatch[2] - if value := selectedBTNAutofillOptionValue(body); value != "" { - fields[name] = value - } - } - return fields -} - -// selectedBTNAutofillOptionValue returns the selected option value regardless -// of attribute order, falling back to the first option value when no option is -// marked selected. -func selectedBTNAutofillOptionValue(body string) string { - tokenizer := xhtml.NewTokenizer(strings.NewReader(body)) - firstValue := "" - for { - switch tokenizer.Next() { - case xhtml.ErrorToken: - return firstValue - case xhtml.StartTagToken, xhtml.SelfClosingTagToken: - token := tokenizer.Token() - if !strings.EqualFold(token.Data, "option") { - continue - } - value := "" - selected := false - for _, attr := range token.Attr { - switch { - case strings.EqualFold(attr.Key, "value"): - value = html.UnescapeString(strings.TrimSpace(attr.Val)) - case strings.EqualFold(attr.Key, "selected"): - selected = true - } - } - if value == "" { - continue - } - if firstValue == "" { - firstValue = value - } - if selected { - return value - } - case xhtml.TextToken, xhtml.EndTagToken, xhtml.CommentToken, xhtml.DoctypeToken: - continue - } - } -} - -// validateAutofill reports whether BTN returned the minimum autofill fields -// needed for the requested upload type. Episodes require a title; season packs -// only require a series artist. -func validateAutofill(fields map[string]string, uploadType string) bool { - artist := strings.TrimSpace(fields["artist"]) - title := strings.TrimSpace(fields["title"]) - if artist == "" { - return false - } - if uploadType == "Episode" && title == "" { - return false - } - if strings.EqualFold(artist, "autofill fail") || strings.EqualFold(title, "autofill fail") { - return false - } - return true -} - -// preferredBTNTVDBEpisodeTitle returns TVDB's English episode title when it is -// available, falling back to the original-language title. -func preferredBTNTVDBEpisodeTitle(tvdb *api.TVDBMetadata) string { - if tvdb == nil { - return "" - } - return metautil.FirstNonEmptyTrimmed(strings.TrimSpace(tvdb.EpisodeNameEnglish), strings.TrimSpace(tvdb.EpisodeName)) -} - -// preferredBTNIMDBEpisodeTitle returns the selected IMDb episode title when an -// episode-specific IMDb entry can be matched to the upload metadata. -func preferredBTNIMDBEpisodeTitle(meta api.PreparedMetadata) string { - if episode := preferredBTNIMDBEpisode(meta); episode != nil { - return strings.TrimSpace(episode.Title) - } - return "" -} - -// preferredBTNTVDBOverview returns TVDB episode overview text before series -// overview text, using English translations before original-language values. -func preferredBTNTVDBOverview(tvdb *api.TVDBMetadata) string { - if tvdb == nil { - return "" - } - return metautil.FirstNonEmptyTrimmed( - strings.TrimSpace(tvdb.EpisodeOverviewEnglish), - strings.TrimSpace(tvdb.EpisodeOverview), - strings.TrimSpace(tvdb.OverviewEnglish), - strings.TrimSpace(tvdb.Overview), - ) -} - -// preferredBTNIMDBOverview returns IMDb plot text when TVDB overview data is -// unavailable for the BTN description block. -func preferredBTNIMDBOverview(imdb *api.IMDBMetadata) string { - if imdb == nil { - return "" - } - return strings.TrimSpace(imdb.Plot) -} - -// buildAlbumDesc keeps BTN's autofilled album_desc when available. If BTN did -// not return one, TV uploads fall back to BBCode episode blocks from TVDB or -// provider/local episode metadata. -func buildAlbumDesc(meta api.PreparedMetadata, fields map[string]string) string { - if desc := metautil.FirstNonEmptyTrimmed(fields["album_desc"]); desc != "" { - return desc - } - if !strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "TV") { - return "" - } - if desc := buildTVDBAlbumDesc(meta); desc != "" { - return desc - } - tvdb := meta.ExternalMetadata.TVDB - overview := metautil.FirstNonEmptyTrimmed(preferredBTNTVDBOverview(tvdb), preferredBTNIMDBOverview(meta.ExternalMetadata.IMDB), strings.TrimSpace(meta.EpisodeOverview)) - aired := metautil.FirstNonEmptyTrimmed(btnTVDBEpisodeAired(tvdb), btnIMDBEpisodeAired(meta), strings.TrimSpace(meta.TVDBAiredDate), strings.TrimSpace(meta.DailyEpisodeDate), "TBA") - season, episode := resolveBTNTVSeasonEpisode(meta) - episodeTitle := metautil.FirstNonEmptyTrimmed(preferredBTNTVDBEpisodeTitle(tvdb), preferredBTNIMDBEpisodeTitle(meta), strings.TrimSpace(meta.EpisodeTitle), "TBA") - return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{{ - SeasonNumber: season, - EpisodeNumber: episode, - EpisodeName: episodeTitle, - EpisodeOverview: overview, - EpisodeAired: aired, - }}) -} - -// buildTVDBAlbumDesc builds BTN album_desc from TVDB data only. Episode uploads -// use the selected episode; season-pack uploads use every fetched episode in -// the selected season. -func buildTVDBAlbumDesc(meta api.PreparedMetadata) string { - tvdb := meta.ExternalMetadata.TVDB - if tvdb == nil { - return "" - } - season, episode := resolveBTNTVSeasonEpisode(meta) - if resolveUploadType(meta) == "Season" { - episodes := make([]api.TVDBEpisodeMetadata, 0, len(tvdb.Episodes)) - for _, candidate := range tvdb.Episodes { - if candidate.SeasonNumber == season { - episodes = append(episodes, candidate) - } - } - sort.SliceStable(episodes, func(i, j int) bool { - return episodes[i].EpisodeNumber < episodes[j].EpisodeNumber - }) - return formatBTNEpisodeAlbumDesc(episodes) - } - for _, candidate := range tvdb.Episodes { - if candidate.SeasonNumber == season && candidate.EpisodeNumber == episode { - return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{candidate}) - } - } - if tvdb.EpisodeSeason > 0 || tvdb.EpisodeNumber > 0 || strings.TrimSpace(tvdb.EpisodeName) != "" || strings.TrimSpace(tvdb.EpisodeOverview) != "" { - return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{{ - SeasonNumber: tvdb.EpisodeSeason, - EpisodeNumber: tvdb.EpisodeNumber, - EpisodeName: tvdb.EpisodeName, - EpisodeNameEnglish: tvdb.EpisodeNameEnglish, - EpisodeOverview: tvdb.EpisodeOverview, - EpisodeOverviewEnglish: tvdb.EpisodeOverviewEnglish, - EpisodeAired: tvdb.EpisodeAired, - EpisodeImage: tvdb.EpisodeImage, - }}) - } - return "" -} - -// formatBTNEpisodeAlbumDesc renders each episode as the BBCode block expected -// by BTN's album_desc field. Empty input returns an empty string. -func formatBTNEpisodeAlbumDesc(episodes []api.TVDBEpisodeMetadata) string { - blocks := make([]string, 0, len(episodes)) - for _, episode := range episodes { - title := metautil.FirstNonEmptyTrimmed(episode.EpisodeNameEnglish, episode.EpisodeName, "TBA") - overview := metautil.FirstNonEmptyTrimmed(episode.EpisodeOverviewEnglish, episode.EpisodeOverview) - aired := metautil.FirstNonEmptyTrimmed(episode.EpisodeAired, "TBA") - var block strings.Builder - block.WriteString("[b]Episode Name:[/b] ") - block.WriteString(title) - block.WriteString("\n[b]Season:[/b] ") - block.WriteString(strconv.Itoa(episode.SeasonNumber)) - block.WriteString("\n[b]Episode:[/b] ") - block.WriteString(strconv.Itoa(episode.EpisodeNumber)) - block.WriteString("\n[b]Aired:[/b] ") - block.WriteString(aired) - block.WriteString("\n\n[b]Episode overview:[/b]\n") - block.WriteString(overview) - if image := strings.TrimSpace(episode.EpisodeImage); image != "" { - block.WriteString("\n\n[b]Episode image:[/b]\n[img=") - block.WriteString(image) - block.WriteString("]") - } - blocks = append(blocks, strings.TrimSpace(block.String())) - } - return strings.Join(blocks, "\n\n") -} - -// btnTVDBEpisodeAired returns the TVDB episode air date used in BTN-generated -// description text. An empty value leaves metadata date fallbacks in control. -func btnTVDBEpisodeAired(tvdb *api.TVDBMetadata) string { - if tvdb == nil { - return "" - } - return strings.TrimSpace(tvdb.EpisodeAired) -} - -// btnIMDBEpisodeAired returns the selected IMDb episode release date in the -// most specific YYYY[-MM[-DD]] form available. -func btnIMDBEpisodeAired(meta api.PreparedMetadata) string { - episode := preferredBTNIMDBEpisode(meta) - if episode == nil || episode.ReleaseDate.Year <= 0 { - return "" - } - if episode.ReleaseDate.Month <= 0 { - return strconv.Itoa(episode.ReleaseDate.Year) - } - if episode.ReleaseDate.Day <= 0 { - return fmt.Sprintf("%04d-%02d", episode.ReleaseDate.Year, episode.ReleaseDate.Month) - } - return fmt.Sprintf("%04d-%02d-%02d", episode.ReleaseDate.Year, episode.ReleaseDate.Month, episode.ReleaseDate.Day) -} - -// preferredBTNIMDBEpisode returns the IMDb episode entry for this upload when -// the canonical season and episode identify one, or the sole available IMDb -// episode when the metadata payload already represents a single episode. -func preferredBTNIMDBEpisode(meta api.PreparedMetadata) *api.IMDBEpisode { - if meta.ExternalMetadata.IMDB == nil || len(meta.ExternalMetadata.IMDB.Episodes) == 0 { - return nil - } - episodes := meta.ExternalMetadata.IMDB.Episodes - season, episode := meta.CanonicalSeasonEpisode() - if season > 0 && episode > 0 { - for i := range episodes { - if episodes[i].Season != season { - continue - } - if btnIMDBEpisodeNumber(episodes[i].EpisodeText) == episode { - return &episodes[i] - } - } - } - if len(episodes) == 1 { - return &episodes[0] - } - return nil -} - -// btnIMDBEpisodeNumber parses IMDb episode text such as "7", "E07", or -// "Episode 7" into the numeric episode value BTN expects. -func btnIMDBEpisodeNumber(value string) int { - value = strings.TrimSpace(value) - if value == "" { - return 0 - } - if parsed, err := strconv.Atoi(value); err == nil { - return parsed - } - matches := btnIMDBEpisodePattern.FindStringSubmatch(value) - if len(matches) < 2 { - return 0 - } - parsed, err := strconv.Atoi(matches[1]) - if err != nil { - return 0 - } - return parsed -} - -// validateBTNTVPayloadMetadata returns the shared BTN TV metadata block reason -// used by live upload, autofill, and dry-run when canonical season or episode -// data is missing. -func validateBTNTVPayloadMetadata(meta api.PreparedMetadata) (string, error) { - message := btnTVPayloadMetadataMessage(meta) - if message == "" { - return "", nil - } - return message, errors.New("trackers: BTN " + message) -} - -func resolveUploadType(meta api.PreparedMetadata) string { - if meta.TVPack { - return "Season" - } - _, episode := resolveBTNTVSeasonEpisode(meta) - if episode > 0 { - return "Episode" - } - return "Season" -} - -// resolveBTNTVSeasonEpisode returns the season and episode numbers BTN should -// use for generated request and description fields. TVDB episode numbers win -// over IMDb episode data, then metadata ints; missing values fall back -// independently. -func resolveBTNTVSeasonEpisode(meta api.PreparedMetadata) (int, int) { - season, episode := meta.CanonicalSeasonEpisode() - tvdb := meta.ExternalMetadata.TVDB - imdbEpisode := preferredBTNIMDBEpisode(meta) - if tvdb != nil && tvdb.EpisodeSeason > 0 { - season = meta.ExternalMetadata.TVDB.EpisodeSeason - } else if imdbEpisode != nil && imdbEpisode.Season > 0 { - season = imdbEpisode.Season - } - if tvdb != nil && tvdb.EpisodeNumber > 0 { - episode = meta.ExternalMetadata.TVDB.EpisodeNumber - } else if imdbEpisode != nil { - if imdbEpisodeNumber := btnIMDBEpisodeNumber(imdbEpisode.EpisodeText); imdbEpisodeNumber > 0 { - episode = imdbEpisodeNumber - } - } - return season, episode -} - -// btnTVPayloadMetadataMessage explains when BTN cannot build TV season or -// episode fields because canonical metadata is absent. Parsed release values are -// reported only as ignored signals, and the message includes the operator action -// required by blocked dry-run entries. -func btnTVPayloadMetadataMessage(meta api.PreparedMetadata) string { - if !strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "TV") { - return "" - } - missing := make([]string, 0, 2) - ignored := make([]string, 0, 2) - season, episode := resolveBTNTVSeasonEpisode(meta) - if season <= 0 { - missing = append(missing, "season") - if meta.Release.Season > 0 { - ignored = append(ignored, "season") - } - } - if episode <= 0 && !meta.TVPack { - missing = append(missing, "episode") - if meta.Release.Episode > 0 { - ignored = append(ignored, "episode") - } - } - if len(missing) == 0 { - return "" - } - message := "canonical TV " + strings.Join(missing, "/") + " missing; BTN upload requires TVDB or metadata season/episode ints" - if len(ignored) > 0 { - message += " and ignores parsed " + strings.Join(ignored, "/") + " fallback" - } - message += "; refresh metadata or correct canonical season/episode before upload" - return message -} - -// resolveOrigin derives the origin from -// prepared scene and season-pack metadata, group tag and username -func resolveOrigin(meta api.PreparedMetadata) string { - group := strings.TrimSpace(meta.Release.Group) - if metadata.DetectSeasonPackGroupTags(meta).Mixed { - return "Mixed" - } - if isBTNSceneRelease(meta) { - return "Scene" - } - if group != "" && isNoGroupTag(group) || isBTNInternalGroup(meta) { - return "None" - } - return "P2P" -} - -// stripEpisodeTitle removes generated episode-title text from BTN upload names -// unless the original filename already carried the same token sequence. -func stripEpisodeTitle(name string, episodeTitle string, filename string) string { - if episodeTitle == "" || name == "" { - return name - } - if metautil.ReleaseNameContainsEpisodeTitle(filename, episodeTitle) { - return name - } - return metautil.RemoveEpisodeTitleFromReleaseName(name, episodeTitle) -} - -func resolveUploadName(meta api.PreparedMetadata) string { - var name string - if n := strings.TrimSpace(meta.ReleaseName); n != "" { - name = n - } else if n := strings.TrimSpace(meta.ReleaseNameNoTag); n != "" { - name = n - } else if n := strings.TrimSpace(meta.Filename); n != "" { - name = n - } else { - name = pathutil.Base(meta.SourcePath) - } - name = stripEpisodeTitle(name, meta.EpisodeTitle, btnEpisodeTitleFilename(meta)) - name = cleanAndNormalizeBTNName(name) - return applyBTNNoGroupSuffix(name, meta) -} - -// btnEpisodeTitleFilename returns the original filename text used to decide -// whether BTN should preserve an episode title in the upload name. -func btnEpisodeTitleFilename(meta api.PreparedMetadata) string { - return metautil.FirstNonEmptyTrimmed(meta.Filename, pathutil.Base(meta.SourcePath)) -} - -// applyBTNNoGroupSuffix preserves a valid prepared group tag for no-tag names -// and falls back to BTN's NOGRP marker when no usable tag is available. -func applyBTNNoGroupSuffix(name string, meta api.PreparedMetadata) string { - tag := strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")) - - if tag != "" && !isNoGroupTag(tag) { - if selectedBTNReleaseNameNoTag(name, meta) || !hasBTNGroupSuffix(name) { - return strings.TrimRight(name, ".-") + "-" + tag - } - return name - } - - // Preserve an existing release-name suffix when parsing did not provide a - // group tag; only known placeholder suffixes should be normalized below. - if tag == "" && hasBTNGroupSuffix(name) && !hasBTNNoGroupSuffix(name) { - return name - } - - noGroupPattern := regexp.MustCompile(`(?i)-(nogrp|nogroup|unknown|unk)$`) - normalizedName := noGroupPattern.ReplaceAllString(name, "") - normalizedName = strings.TrimRight(normalizedName, ".-") - - return normalizedName + "-NOGRP" -} - -// selectedBTNReleaseNameNoTag reports whether name is the normalized -// ReleaseNameNoTag value chosen by resolveUploadName. -func selectedBTNReleaseNameNoTag(name string, meta api.PreparedMetadata) bool { - if strings.TrimSpace(meta.ReleaseName) != "" || strings.TrimSpace(meta.ReleaseNameNoTag) == "" { - return false - } - candidate := stripEpisodeTitle(strings.TrimSpace(meta.ReleaseNameNoTag), meta.EpisodeTitle, btnEpisodeTitleFilename(meta)) - candidate = cleanAndNormalizeBTNName(candidate) - return strings.TrimSpace(name) == candidate -} - -// hasBTNGroupSuffix reports whether name already ends with a hyphenated BTN -// group suffix. -func hasBTNGroupSuffix(name string) bool { - return regexp.MustCompile(`-[^-.\s]+$`).MatchString(strings.TrimSpace(name)) -} - -// hasBTNNoGroupSuffix reports whether name ends with a placeholder group suffix -// that BTN should normalize to NOGRP. -func hasBTNNoGroupSuffix(name string) bool { - return regexp.MustCompile(`(?i)-(nogrp|nogroup|unknown|unk)$`).MatchString(strings.TrimSpace(name)) -} - -func isNoGroupTag(tag string) bool { - value := strings.ToLower(strings.TrimSpace(tag)) - switch value { - case "nogrp", "nogroup", "unknown", "unk": - return true - default: - return false - } -} - -func removeDiacritics(s string) string { - t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) - result, _, _ := transform.String(t, s) - return result -} - -// cleanAndNormalizeBTNName converts prepared release names into BTN scene-name -// syntax. It removes diacritics, uses dots as token separators, compacts known -// audio channel tokens, and keeps hyphens for group tags. -func cleanAndNormalizeBTNName(value string) string { - // 0. Remove diacritics - value = removeDiacritics(value) - - // 1. Dot normalization (spaces to dots, collapse dots) - value = strings.Join(strings.Fields(value), " ") - value = strings.ReplaceAll(value, " ", ".") - - // 2. Replace plus in DD+ - value = strings.ReplaceAll(value, "DD+", "DDP") - - for _, rule := range btnNameNormalizationRules { - value = rule.pattern.ReplaceAllString(value, rule.replacement) - } - - return strings.TrimSpace(value) -} - -func resolveTorrentPath(meta api.PreparedMetadata, dbPath string) (string, error) { - candidates := []string{strings.TrimSpace(meta.TorrentPath), strings.TrimSpace(meta.ClientTorrentPath), strings.TrimSpace(meta.SourcePath)} - for _, candidate := range candidates { - if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { - continue - } - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate, nil - } - } - if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err == nil { - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err == nil { - guessed := filepath.Join(tmpDir, base+".torrent") - if info, err := os.Stat(guessed); err == nil && !info.IsDir() { - return guessed, nil - } - } - } - } - return "", errors.New("trackers: BTN torrent file not found") -} - -// resolveBTNUploadFiles returns the multipart file parts BTN accepts for an -// upload. Scene NFOs are attached only when scene metadata confirms the release -// and the prepared NFO file still exists. -func resolveBTNUploadFiles(meta api.PreparedMetadata, torrentPath string) []commonhttp.FileField { - files := []commonhttp.FileField{{ - FieldName: "file_input", - Path: torrentPath, - FileName: "torrent.torrent", - }} - if nfoPath := resolveBTNSceneNFOPath(meta); nfoPath != "" { - files = append(files, commonhttp.FileField{ - FieldName: "nfo", - Path: nfoPath, - FileName: filepath.Base(nfoPath), - }) - } - return files -} - -// resolveBTNDryRunFiles mirrors the upload file parts without reading file -// content so previews show whether BTN will receive an NFO. -func resolveBTNDryRunFiles(meta api.PreparedMetadata, torrentPath string) []api.TrackerDryRunFile { - files := []api.TrackerDryRunFile{{ - Field: "file_input", - Path: torrentPath, - Present: strings.TrimSpace(torrentPath) != "", - }} - if nfoPath := resolveBTNSceneNFOPath(meta); nfoPath != "" { - files = append(files, api.TrackerDryRunFile{Field: "nfo", Path: nfoPath, Present: true}) - } - return files -} - -func resolveBTNSceneNFOPath(meta api.PreparedMetadata) string { - if !isBTNSceneRelease(meta) { - return "" - } - nfoPath := strings.TrimSpace(meta.SceneNFOPath) - if nfoPath == "" { - return "" - } - if info, err := os.Stat(nfoPath); err == nil && !info.IsDir() { - return nfoPath - } - return "" -} - -func isBTNSceneRelease(meta api.PreparedMetadata) bool { - return meta.Scene || strings.TrimSpace(meta.SceneName) != "" -} - -func resolveTrackerTorrentPath(meta api.PreparedMetadata, dbPath string, tracker string) (string, error) { - if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { - return "", errors.New("trackers: BTN tracker torrent path requires db path and source path") - } - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: BTN tmp root: %w", err) - } - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: BTN tmp release dir: %w", err) - } - name := strings.ToLower(strings.TrimSpace(tracker)) - if name == "" { - name = "tracker" - } - return filepath.Join(tmpDir, base+"."+name+".torrent"), nil -} - -// writeBTNTorrentArtifact rewrites the uploaded torrent with the configured BTN -// announce URL once the canonical torrent URL is known. A successful write lets -// upload skip downloading BTN's generated torrent file. -func writeBTNTorrentArtifact(sourcePath string, outputPath string, announceURL string, torrentURL string) error { - if err := trackers.WritePersonalizedTorrent(sourcePath, outputPath, announceURL, torrentURL, "BTN"); err != nil { - return fmt.Errorf("trackers: BTN write torrent artifact: %w", err) - } - return nil -} - -// downloadTrackerTorrent fetches BTN's generated torrent file for uploads that -// do not have a configured announce URL. Non-torrent responses are rejected so -// callers can fall back to API resolution. -func downloadTrackerTorrent(ctx context.Context, client *http.Client, baseURL string, torrentID string, outputPath string) error { - if strings.TrimSpace(torrentID) == "" { - return errors.New("trackers: BTN torrent_id missing") - } - downloadURL := strings.TrimRight(baseURL, "/") + "/torrents.php?action=download&id=" + url.QueryEscape(strings.TrimSpace(torrentID)) - return downloadBTNTorrentURL(ctx, client, downloadURL, outputPath) -} - -// downloadBTNTorrentURL fetches a BTN torrent download URL and writes only a -// bencoded torrent payload to outputPath. -func downloadBTNTorrentURL(ctx context.Context, client *http.Client, downloadURL string, outputPath string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return fmt.Errorf("trackers: BTN torrent download request build: %w", err) - } - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("trackers: BTN torrent download request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("status %d", resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024)) - if err != nil { - return fmt.Errorf("trackers: BTN read torrent response: %w", err) - } - if len(body) == 0 || body[0] != 'd' { - return errors.New("not a torrent payload") - } - if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { - return fmt.Errorf("trackers: BTN create torrent output dir: %w", err) - } - if err := os.WriteFile(outputPath, body, 0o600); err != nil { - return fmt.Errorf("trackers: BTN write torrent output: %w", err) - } - return nil -} - -type btnUploadIntermediateResult struct { - groupID string - torrentID string -} - -// resolveBTNUploadIntermediatePage handles BTN's post-upload warning page that -// requires continuing to the canonical torrent page before the final torrent id -// is available. It returns handled=false for ordinary upload responses. -func resolveBTNUploadIntermediatePage(ctx context.Context, client *http.Client, baseURL string, currentURL string, body []byte) (btnUploadIntermediateResult, bool, error) { - if !isBTNUploadIntermediatePage(body) { - return btnUploadIntermediateResult{}, false, nil - } - - result := btnUploadIntermediateResult{} - detailURL, detailGroupID, detailTorrentID, ok := findBTNUploadDetailURL(baseURL, currentURL, body) - canonicalTorrentID := false - if ok { - result.groupID = detailGroupID - if detailTorrentID != "" { - result.torrentID = detailTorrentID - canonicalTorrentID = true - } - } - - if !ok { - return result, true, errors.New("trackers: BTN intermediate page missing torrent detail link") - } - detailBody, detailFinalURL, err := fetchBTNTorrentDetailPage(ctx, client, detailURL) - if err != nil { - return result, true, err - } - if groupID, torrentID, matched := btnUploadIDsFromText(detailFinalURL); matched { - result.groupID = groupID - if torrentID != "" { - result.torrentID = torrentID - return result, true, nil - } - } - if groupID, torrentID, matched := btnUploadIDsFromText(string(detailBody)); matched { - result.groupID = groupID - if torrentID != "" { - result.torrentID = torrentID - return result, true, nil - } - } - if result.groupID == "" || !canonicalTorrentID { - return result, true, errors.New("trackers: BTN intermediate detail page missing canonical torrent id") - } - return result, true, nil -} - -// isBTNUploadIntermediatePage detects BTN's warning page that appears after a -// successful upload before the user has downloaded the generated torrent file. -func isBTNUploadIntermediatePage(body []byte) bool { - normalized := strings.ToLower(html.UnescapeString(string(body))) - return strings.Contains(normalized, "download the torrent file") || - strings.Contains(normalized, "need to download the torrent") -} - -// findBTNUploadDetailURL extracts the same-origin canonical torrent page URL -// from an intermediate BTN upload page. -func findBTNUploadDetailURL(baseURL string, currentURL string, body []byte) (string, string, string, bool) { - for _, raw := range btnHTMLURLAttrPattern.FindAllStringSubmatch(string(body), -1) { - if len(raw) < 2 { - continue - } - candidate, ok := resolveBTNSameOriginURL(baseURL, currentURL, raw[1]) - if !ok || !strings.EqualFold(candidate.Path, "/torrents.php") { - continue - } - query := candidate.Query() - if strings.EqualFold(query.Get("action"), "download") { - continue - } - groupID := strings.TrimSpace(query.Get("id")) - if groupID == "" { - continue - } - torrentID := strings.TrimSpace(query.Get("torrentid")) - return candidate.String(), groupID, torrentID, true - } - return "", "", "", false -} - -// resolveBTNSameOriginURL resolves an HTML URL attribute against the current -// BTN page and accepts only URLs on the configured BTN origin. -func resolveBTNSameOriginURL(baseURL string, currentURL string, rawURL string) (*url.URL, bool) { - base, err := url.Parse(baseURL) - if err != nil || base.Scheme == "" || base.Host == "" { - return nil, false - } - referenceBase := base - if parsedCurrent, err := url.Parse(currentURL); err == nil && parsedCurrent.Scheme != "" && parsedCurrent.Host != "" { - referenceBase = parsedCurrent - } - rawURL = strings.TrimSpace(html.UnescapeString(rawURL)) - if rawURL == "" { - return nil, false - } - parsed, err := url.Parse(rawURL) - if err != nil { - return nil, false - } - resolved := referenceBase.ResolveReference(parsed) - if !strings.EqualFold(resolved.Scheme, base.Scheme) || !strings.EqualFold(resolved.Host, base.Host) { - return nil, false - } - return resolved, true -} - -// fetchBTNTorrentDetailPage follows the intermediate-page continue target and -// returns the bounded HTML body with the final URL after redirects. -func fetchBTNTorrentDetailPage(ctx context.Context, client *http.Client, detailURL string) ([]byte, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil) - if err != nil { - return nil, "", fmt.Errorf("trackers: BTN intermediate detail request build: %w", err) - } - resp, err := client.Do(req) - if err != nil { - return nil, "", fmt.Errorf("trackers: BTN intermediate detail request: %w", err) - } - defer resp.Body.Close() - finalURL := detailURL - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, finalURL, fmt.Errorf("trackers: BTN intermediate detail failed status=%d", resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - if err != nil { - return nil, finalURL, fmt.Errorf("trackers: BTN read intermediate detail response: %w", err) - } - return body, finalURL, nil -} - -// btnUploadIDsFromText extracts BTN group and torrent ids from a URL or HTML -// fragment, including HTML-escaped query separators. -func btnUploadIDsFromText(value string) (string, string, bool) { - matches := btnSuccessURLPattern.FindStringSubmatch(html.UnescapeString(value)) - if len(matches) < 2 { - return "", "", false - } - groupID := strings.TrimSpace(matches[1]) - torrentID := "" - if len(matches) > 2 { - torrentID = strings.TrimSpace(matches[2]) - } - return groupID, torrentID, groupID != "" -} - -// buildBTNTorrentURL returns BTN's canonical torrent detail URL for a group and -// optional torrent id. -func buildBTNTorrentURL(baseURL string, groupID string, torrentID string) string { - torrentURL := strings.TrimRight(baseURL, "/") + "/torrents.php?id=" + url.QueryEscape(strings.TrimSpace(groupID)) - if strings.TrimSpace(torrentID) != "" { - torrentURL += "&torrentid=" + url.QueryEscape(strings.TrimSpace(torrentID)) - } - return torrentURL -} - -// decodeBTNAPIJSON reads a bounded BTN JSON-RPC response, rejects duplicate -// object keys, and unmarshals the single JSON value into dest. -func decodeBTNAPIJSON(r io.Reader, dest any) error { - payload, err := io.ReadAll(io.LimitReader(r, btnAPIJSONMaxBytes+1)) - if err != nil { - return fmt.Errorf("read response body: %w", err) - } - if len(payload) > btnAPIJSONMaxBytes { - return fmt.Errorf("response body exceeds %d bytes", btnAPIJSONMaxBytes) - } - if err := validateBTNJSONNoDuplicateObjectNames(payload); err != nil { - return err - } - if err := json.Unmarshal(payload, dest); err != nil { - return fmt.Errorf("unmarshal response body: %w", err) - } - return nil -} - -func callBTNAPI(ctx context.Context, apiURL, id, method string, params []any, dest any) error { - payload := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params, - } - encoded, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("trackers: BTN API %s encode: %w", method, err) - } - apiReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(encoded)) - if err != nil { - return fmt.Errorf("trackers: BTN API %s request build: %w", method, err) - } - apiReq.Header.Set("Content-Type", "application/json") - apiResp, err := (&http.Client{Timeout: 30 * time.Second}).Do(apiReq) - if err != nil { - return fmt.Errorf("trackers: BTN API %s request: %w", method, err) - } - defer apiResp.Body.Close() - if apiResp.StatusCode < 200 || apiResp.StatusCode >= 300 { - return fmt.Errorf("trackers: BTN API %s failed status=%d", method, apiResp.StatusCode) - } - if err := decodeBTNAPIJSON(apiResp.Body, dest); err != nil { - return fmt.Errorf("trackers: BTN API decode %s response: %w", method, err) - } - return nil -} - -// validateBTNJSONNoDuplicateObjectNames scans one JSON value before unmarshal -// so duplicate object names cannot collapse into the last decoded value. -func validateBTNJSONNoDuplicateObjectNames(payload []byte) error { - dec := json.NewDecoder(bytes.NewReader(payload)) - dec.UseNumber() - if err := validateBTNJSONValueNoDuplicateObjectNames(dec, ""); err != nil { - return err - } - if _, err := dec.Token(); err != io.EOF { - if err == nil { - return errors.New("multiple JSON values") - } - return fmt.Errorf("read trailing JSON token: %w", err) - } - return nil -} - -// validateBTNJSONValueNoDuplicateObjectNames consumes one JSON value and -// reports duplicate object member names with their object path. -func validateBTNJSONValueNoDuplicateObjectNames(dec *json.Decoder, path string) error { - token, err := dec.Token() - if err != nil { - return fmt.Errorf("read JSON token at %q: %w", path, err) - } - delim, ok := token.(json.Delim) - if !ok { - return nil - } - - switch delim { - case '{': - seen := map[string]struct{}{} - for dec.More() { - keyToken, err := dec.Token() - if err != nil { - return fmt.Errorf("read JSON object key at %q: %w", path, err) - } - key, ok := keyToken.(string) - if !ok { - return fmt.Errorf("invalid JSON object key at %q", path) - } - if _, exists := seen[key]; exists { - if path == "" { - return fmt.Errorf("duplicate JSON object key %q", key) - } - return fmt.Errorf("duplicate JSON object key %q at %q", key, path) - } - seen[key] = struct{}{} - childPath := key - if path != "" { - childPath = path + "." + key - } - if err := validateBTNJSONValueNoDuplicateObjectNames(dec, childPath); err != nil { - return err - } - } - return consumeBTNJSONDelim(dec, '}') - case '[': - index := 0 - for dec.More() { - childPath := fmt.Sprintf("%s[%d]", path, index) - if err := validateBTNJSONValueNoDuplicateObjectNames(dec, childPath); err != nil { - return err - } - index++ - } - return consumeBTNJSONDelim(dec, ']') - default: - return fmt.Errorf("unexpected JSON delimiter %q at %q", delim, path) - } -} - -// consumeBTNJSONDelim consumes and verifies a JSON closing delimiter. -func consumeBTNJSONDelim(dec *json.Decoder, want json.Delim) error { - token, err := dec.Token() - if err != nil { - return fmt.Errorf("read JSON delimiter %q: %w", want, err) - } - if delim, ok := token.(json.Delim); !ok || delim != want { - return fmt.Errorf("expected JSON delimiter %q", want) - } - return nil -} - -// resolveAndDownloadViaAPI finds the uploaded torrent through BTN's JSON-RPC -// API, validates the returned DownloadURL, and writes the fetched bencoded -// torrent to outputPath. The selected BTN torrent and group ids are returned -// so upload summaries reflect the torrent that was actually downloaded. -func resolveAndDownloadViaAPI(ctx context.Context, apiURL string, apiToken string, req trackers.UploadRequest, groupID string, outputPath string) (string, string, error) { - if strings.TrimSpace(apiToken) == "" { - return "", "", errors.New("trackers: BTN api token missing for torrent resolution") - } - if strings.TrimSpace(apiURL) == "" { - apiURL = btnAPIRPCURL - } - downloadOrigin, err := newBTNAPIDownloadOrigin(ctx, apiURL) - if err != nil { - return "", "", fmt.Errorf("trackers: BTN API download origin: %w", err) - } - releaseName := resolveUploadName(req.Meta) - filter := map[string]any{"searchstr": releaseName} - if strings.TrimSpace(groupID) != "" { - filter["group"] = groupID - } - - var response struct { - Result struct { - Torrents map[string]map[string]any `json:"torrents"` - } `json:"result"` - } - if err := callBTNAPI(ctx, apiURL, "ua-btn-upload", "getTorrentsSearch", []any{apiToken, filter, 50}, &response); err != nil { - return "", "", err - } - - selection := selectBTNAPITorrent(response.Result.Torrents, releaseName, groupID) - if selection.ID == "" { - return "", "", errors.New("trackers: BTN API did not return a matching torrent id") - } - - var downloadResult struct { - Result struct { - DownloadURL string `json:"DownloadURL"` - } `json:"result"` - } - if err := callBTNAPI(ctx, apiURL, "ua-btn-download", "getTorrentById", []any{apiToken, selection.ID}, &downloadResult); err != nil { - return "", "", err - } - - if downloadResult.Result.DownloadURL == "" { - return "", "", errors.New("trackers: BTN API did not return DownloadURL") - } - - if err := downloadOrigin.validateDownloadURL(ctx, downloadResult.Result.DownloadURL); err != nil { - return "", "", fmt.Errorf("trackers: BTN API invalid download url: %w", err) - } - - dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadResult.Result.DownloadURL, nil) - if err != nil { - return "", "", fmt.Errorf("trackers: BTN API torrent fetch request build: %w", err) - } - client := &http.Client{ - Timeout: 30 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if err := downloadOrigin.validateDownloadURL(req.Context(), req.URL.String()); err != nil { - return err - } - if len(via) >= 10 { - return errors.New("stopped after 10 redirects") - } - return nil - }, - } - dlResp, err := client.Do(dlReq) - if err != nil { - return "", "", fmt.Errorf("trackers: BTN API torrent fetch request: %w", err) - } - defer dlResp.Body.Close() - if dlResp.StatusCode < 200 || dlResp.StatusCode >= 300 { - return "", "", fmt.Errorf("trackers: BTN API download fetch failed status=%d", dlResp.StatusCode) - } - body, err := readBTNTorrentResponseBody(dlResp.Body) - if err != nil { - return "", "", fmt.Errorf("trackers: BTN API read torrent response: %w", err) - } - if len(body) == 0 || body[0] != 'd' { - return "", "", errors.New("trackers: BTN API did not return torrent payload") - } - if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { - return "", "", fmt.Errorf("trackers: BTN API create torrent output dir: %w", err) - } - if err := os.WriteFile(outputPath, body, 0o600); err != nil { - return "", "", fmt.Errorf("trackers: BTN API write torrent output: %w", err) - } - return selection.ID, selection.GroupID, nil -} - -// readBTNTorrentResponseBody rejects bodies beyond BTN's accepted torrent -// response cap before callers validate or persist the payload. -func readBTNTorrentResponseBody(r io.Reader) ([]byte, error) { - body, err := io.ReadAll(io.LimitReader(r, btnTorrentMaxBytes+1)) - if err != nil { - return nil, fmt.Errorf("read torrent response: %w", err) - } - if len(body) > btnTorrentMaxBytes { - return nil, fmt.Errorf("torrent response exceeds %d bytes", btnTorrentMaxBytes) - } - return body, nil -} - -// loadBTNCookiesIntoJar best-effort seeds an upload client with persisted BTN -// cookies. Missing or unreadable cookies leave the caller's client unchanged. -func loadBTNCookiesIntoJar(ctx context.Context, client *http.Client, dbPath string, baseURL string) { - if client == nil || client.Jar == nil { - return - } - values, err := loadBTNCookies(ctx, dbPath) - if err != nil { - return - } - setBTNCookies(client.Jar, baseURL, values) -} - -// loadBTNCookies reads persisted BTN cookies and maps only typed not-found -// results to the BTN missing-cookie sentinel. Storage, parse, and decrypt errors -// are returned with tracker context so callers can avoid replacing valid state. -func loadBTNCookies(ctx context.Context, dbPath string) (map[string]string, error) { - values, err := cookies.LoadTrackerCookieMap(ctx, dbPath, "BTN") - if err != nil { - if errors.Is(err, cookies.ErrTrackerCookiesNotFound) { - return nil, errBTNCookiesMissing - } - return nil, fmt.Errorf("trackers: %w", err) - } - return values, nil -} - -// newBTNClientWithCookies creates a short-lived BTN client with a fresh cookie -// jar populated from the supplied stored cookie map. -func newBTNClientWithCookies(baseURL string, values map[string]string) (*http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("trackers: BTN create session cookie jar: %w", err) - } - setBTNCookies(jar, baseURL, values) - return &http.Client{Timeout: 45 * time.Second, Jar: jar}, nil -} - -// setBTNCookies mirrors stored BTN cookie values into jar for baseURL. Invalid -// base URLs or nil jars are ignored because callers treat cookie seeding as -// best-effort before explicit session validation. -func setBTNCookies(jar http.CookieJar, baseURL string, values map[string]string) { - if jar == nil { - return - } - parsed, err := url.Parse(baseURL) - if err != nil { - return - } - jarCookies := make([]*http.Cookie, 0, len(values)) - for name, value := range values { - // #nosec G124 -- Outbound tracker jar cookie mirrors configured BTN session values. - jarCookies = append(jarCookies, &http.Cookie{Name: name, Value: value, Domain: parsed.Hostname(), Path: "/"}) - } - jar.SetCookies(parsed, jarCookies) -} - -// persistBTNCookies saves cookies extracted from a caller-validated BTN client jar. -func persistBTNCookies(ctx context.Context, dbPath string, baseURL string, jar http.CookieJar) error { - values, err := btnCookiesFromJar(baseURL, jar) - if err != nil { - return err - } - if len(values) == 0 { - return errors.New("trackers: BTN login returned no usable cookies") - } - if err := cookies.SaveTrackerCookieMap(ctx, dbPath, "BTN", values); err != nil { - return fmt.Errorf("trackers: BTN save cookies: %w", err) - } - return nil -} - -// btnCookiesFromJar extracts non-empty BTN cookie names and values for baseURL -// after a caller has validated that the jar represents a usable session. -func btnCookiesFromJar(baseURL string, jar http.CookieJar) (map[string]string, error) { - if jar == nil { - return nil, errors.New("trackers: BTN login returned no cookie jar") - } - parsed, err := url.Parse(baseURL) - if err != nil { - return nil, fmt.Errorf("trackers: BTN parse cookie URL: %w", err) - } - out := make(map[string]string) - for _, cookie := range jar.Cookies(parsed) { - if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { - continue - } - out[strings.TrimSpace(cookie.Name)] = cookie.Value - } - return out, nil -} - -// resolveBTN2FACode returns a manually submitted code before falling back to -// the configured TOTP URI. -func resolveBTN2FACode(cfg config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { - if code := strings.TrimSpace(login.Code); code != "" { - return code, nil - } - return resolve2FACode(strings.TrimSpace(cfg.OTPURI)) -} - -// btnLoginNeeds2FA recognizes BTN login responses that render the manual 2FA -// challenge field instead of accepting the submitted credentials. -func btnLoginNeeds2FA(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, "name=\"codenumber\"") || - strings.Contains(lower, "name='codenumber'") -} - -// btnLoginFailed recognizes explicit BTN credential or submitted-code failures -// in successful HTTP responses. -func btnLoginFailed(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, "invalid login") || - strings.Contains(lower, "incorrect password") || - strings.Contains(lower, "invalid code") || - strings.Contains(lower, "incorrect code") || - strings.Contains(lower, "login failed") -} - -// btnLoggedOutPage recognizes upload-page responses that prove the session is -// logged out and safe to classify as confirmed-invalid auth. -func btnLoggedOutPage(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, " 0 { - period = parsedValue - } - } - decoder := base32.StdEncoding.WithPadding(base32.NoPadding) - secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) - if err != nil { - return "", fmt.Errorf("trackers: BTN decode otp secret: %w", err) - } - counterTime := time.Now().Unix() / int64(period) - if counterTime < 0 { - return "", errors.New("totp counter before unix epoch") - } - counter := uint64(counterTime) - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, counter) - mac := hmac.New(sha1.New, secretBytes) - _, _ = mac.Write(buf) - hash := mac.Sum(nil) - offset := hash[len(hash)-1] & 0x0f - code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) - return fmt.Sprintf("%06d", code%1000000), nil -} - -func resolveBTNAPIURL(cfg config.TrackerConfig) string { - if cfg.Unknown != nil { - if raw, ok := cfg.Unknown["api_url"]; ok { - if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" { - return value - } - } - } - return btnAPIRPCURL -} - -func resolveFastTorrent(cfg config.TrackerConfig) bool { - if cfg.Unknown != nil { - if raw, ok := cfg.Unknown["fast_torrent"]; ok { - if b, ok := raw.(bool); ok { - return b - } - if s, ok := raw.(string); ok { - return strings.EqualFold(strings.TrimSpace(s), "true") || strings.TrimSpace(s) == "1" - } - } - } - return false -} - -func stripHTML(value string) string { - replacer := strings.NewReplacer("
", "\n", "
", "\n", "
", "\n") - cleaned := replacer.Replace(value) - cleaned = regexp.MustCompile(`(?s)<[^>]*>`).ReplaceAllString(cleaned, "") - return strings.TrimSpace(cleaned) -} - -// mapContainer maps local container metadata to BTN's format dropdown. Autofill -// is used only when metadata does not resolve to a BTN-supported value. -func mapContainer(meta api.PreparedMetadata, fields map[string]string) string { - allowed := map[string]struct{}{"AVI": {}, "MKV": {}, "VOB": {}, "MPEG": {}, "MP4": {}, "ISO": {}, "WMV": {}, "TS": {}, "M4V": {}, "M2TS": {}, "Mixed": {}} - container := strings.ToLower(strings.TrimSpace(meta.Container)) - mapped := map[string]string{"avi": "AVI", "mkv": "MKV", "vob": "VOB", "mpg": "MPEG", "mpeg": "MPEG", "mp4": "MP4", "iso": "ISO", "wmv": "WMV", "ts": "TS", "m4v": "M4V", "m2ts": "M2TS"}[container] - if mapped == "" && strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - mapped = "M2TS" - } - if mapped == "" && strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - mapped = "VOB" - } - for _, candidate := range []string{mapped, fields["format"], "Mixed"} { - if _, ok := allowed[candidate]; ok { - return candidate - } - } - return "" -} - -// mapCodec maps local video codec metadata to BTN's bitrate dropdown. Autofill -// is used only when metadata does not resolve to a BTN-supported value. -func mapCodec(meta api.PreparedMetadata, fields map[string]string) string { - allowed := map[string]struct{}{"XViD": {}, "MPEG2": {}, "DiVX": {}, "DVDR": {}, "VC-1": {}, "H.264": {}, "H.265": {}, "WMV": {}, "BD": {}, "x264-Hi10P": {}, "VP9": {}, "Mixed": {}} - videoEncode := strings.ToLower(strings.TrimSpace(meta.VideoEncode)) - videoCodec := strings.ToLower(strings.TrimSpace(meta.VideoCodec)) - bitDepth := strings.TrimSpace(meta.BitDepth) - mapped := "" - if (strings.Contains(videoEncode, "hi10") || bitDepth == "10") && (strings.Contains(videoEncode, "x264") || strings.Contains(videoCodec, "avc") || strings.Contains(videoCodec, "h.264")) { - mapped = "x264-Hi10P" - } - if mapped == "" { - lookup := map[string]string{"xvid": "XViD", "divx": "DiVX", "mpeg-2": "MPEG2", "mpeg2": "MPEG2", "vc-1": "VC-1", "wmv": "WMV", "vp9": "VP9", "avc": "H.264", "h.264": "H.264", "h264": "H.264", "x264": "H.264", "hevc": "H.265", "h.265": "H.265", "h265": "H.265", "x265": "H.265"} - for _, value := range []string{videoEncode, videoCodec} { - for needle, resolved := range lookup { - if strings.Contains(value, needle) { - mapped = resolved - break - } - } - if mapped != "" { - break - } - } - } - for _, candidate := range []string{mapped, fields["bitrate"], "Mixed"} { - if _, ok := allowed[candidate]; ok { - return candidate - } - } - return "" -} - -// mapSource maps local source metadata to BTN's media dropdown. Autofill is -// used only when metadata does not resolve to a BTN-supported value. -func mapSource(meta api.PreparedMetadata, fields map[string]string) string { - allowed := map[string]struct{}{"HDTV": {}, "PDTV": {}, "DSR": {}, "DVDRip": {}, "TVRip": {}, "VHSRip": {}, "Bluray": {}, "BDRip": {}, "BRRip": {}, "DVD5": {}, "DVD9": {}, "HDDVD": {}, "WEB-DL": {}, "WEBRip": {}, "BD5": {}, "BD9": {}, "BD25": {}, "BD50": {}, "Mixed": {}, "Unknown": {}} - source := strings.ToLower(strings.TrimSpace(meta.Source)) - typeName := strings.ToUpper(strings.TrimSpace(meta.Type)) - resolution := strings.ToUpper(strings.TrimSpace(meta.Release.Resolution)) - var mapped string - switch { - case strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD"): - mapped = "DVD9" - case strings.EqualFold(strings.TrimSpace(meta.DiscType), "HDDVD"): - mapped = "HDDVD" - case typeName == "WEBDL": - mapped = "WEB-DL" - case typeName == "WEBRIP": - mapped = "WEBRip" - case typeName == "HDTV" || source == "hdtv": - mapped = "HDTV" - case typeName == "DVDRIP": - mapped = "DVDRip" - case resolution == "SD" && (source == "bluray" || source == "blu-ray"): - mapped = "BDRip" - default: - mapped = map[string]string{"bluray": "Bluray", "blu-ray": "Bluray", "bdrip": "BDRip", "brrip": "BRRip", "dvd5": "DVD5", "dvd9": "DVD9", "web-dl": "WEB-DL", "webrip": "WEBRip", "pdtv": "PDTV", "dsr": "DSR", "tvrip": "TVRip", "vhsrip": "VHSRip", "bd5": "BD5", "bd9": "BD9", "bd25": "BD25", "bd50": "BD50"}[source] - } - for _, candidate := range []string{mapped, fields["media"], "Unknown"} { - if _, ok := allowed[candidate]; ok { - return candidate - } - } - return "" -} - -// mapResolution returns the BTN resolution value derived from local metadata, -// falling back to BTN autofill only when metadata does not map to a BTN option. -func mapResolution(meta api.PreparedMetadata, fields map[string]string) string { - switch strings.ToLower(strings.TrimSpace(meta.Release.Resolution)) { - case "2160p", "4320p", "8640p", "4k", "8k": - return "2160p" - case "1080p", "1440p": - return "1080p" - case "1080i": - return "1080i" - case "720p": - return "720p" - case "sd": - return "SD" - case "portable device": - return "Portable Device" - case "mixed": - return "Mixed" - } - switch strings.TrimSpace(fields["resolution"]) { - case "SD", "720p", "1080p", "1080i", "2160p", "Portable Device", "Mixed": - return strings.TrimSpace(fields["resolution"]) - default: - return "SD" - } -} - -func applyBTNNameMapping(releaseName string, mappedCodec string, mappedSource string) string { - updated := releaseName - if mappedSource != "" { - sourcePattern := regexp.MustCompile(`(?i)\b(bluray|blu-ray|bdrip|brrip|web-dl|webrip|hdtv|dvdrip|hddvd|dvd5|dvd9|bd5|bd9|bd25|bd50)\b`) - updated = sourcePattern.ReplaceAllString(updated, mappedSource) - } - if mappedCodec != "" { - codecPatterns := map[string]*regexp.Regexp{ - "H.264": regexp.MustCompile(`(?i)\b(x264|h\.264|h264|avc)\b`), - "H.265": regexp.MustCompile(`(?i)\b(x265|h\.265|h265|hevc)\b`), - "x264-Hi10P": regexp.MustCompile(`(?i)\b(x264-hi10p|hi10p)\b`), - "XViD": regexp.MustCompile(`(?i)\b(xvid)\b`), - "DiVX": regexp.MustCompile(`(?i)\b(divx)\b`), - "MPEG2": regexp.MustCompile(`(?i)\b(mpeg-2|mpeg2)\b`), - "VC-1": regexp.MustCompile(`(?i)\b(vc-1)\b`), - "WMV": regexp.MustCompile(`(?i)\b(wmv)\b`), - "VP9": regexp.MustCompile(`(?i)\b(vp9)\b`), - } - if pattern, ok := codecPatterns[mappedCodec]; ok { - updated = pattern.ReplaceAllString(updated, mappedCodec) - } - } - return updated -} - -// resolveCountryID extracts the first available country from TVDB, TMDB, then -// IMDB metadata and returns its BTN country id. Country codes and names are -// matched only against normalized exact aliases so ambiguous inputs do not -// depend on map iteration order. -func resolveCountryID(meta api.PreparedMetadata) string { - var countryStr string - - // Try TVDB first (ISO 3166-1 alpha-3, lowercase) - if meta.ExternalMetadata.TVDB != nil && meta.ExternalMetadata.TVDB.OriginalCountry != "" { - countryStr = meta.ExternalMetadata.TVDB.OriginalCountry - } - - // Fall back to TMDB (ISO 3166-1 alpha-2, uppercase) - if countryStr == "" && meta.ExternalMetadata.TMDB != nil && len(meta.ExternalMetadata.TMDB.OriginCountry) > 0 { - countryStr = meta.ExternalMetadata.TMDB.OriginCountry[0] - } - - // Fall back to IMDB (full country names) - if countryStr == "" && meta.ExternalMetadata.IMDB != nil && meta.ExternalMetadata.IMDB.Country != "" { - // IMDB can have multiple countries separated by commas, take the first one - parts := strings.Split(meta.ExternalMetadata.IMDB.Country, ",") - if len(parts) > 0 { - countryStr = strings.TrimSpace(parts[0]) - } - } - - if countryStr == "" { - return "" - } - - if id, ok := btnCountryMap[normalizeBTNCountryAlias(countryStr)]; ok { - return id - } - - return "" -} - -// resolveBTNOriginalLanguage returns provider original language in BTN -// priority order: TVDB first, then IMDb when TVDB has no value. -func resolveBTNOriginalLanguage(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TVDB != nil { - if language := strings.TrimSpace(meta.ExternalMetadata.TVDB.OriginalLanguage); language != "" { - return language - } - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.OriginalLanguage) - } - return "" -} - -// isBTNEnglishLanguage reports whether a provider language value represents -// English and should therefore not trigger BTN's foreign flag. -func isBTNEnglishLanguage(language string) bool { - switch strings.ToLower(strings.TrimSpace(language)) { - case "en", "eng", "english": - return true - default: - return false - } -} - -// normalizeBTNCountryAlias lowercases and collapses punctuation so metadata -// country names can be compared against BTN's exact alias table. -func normalizeBTNCountryAlias(value string) string { - normalized := strings.ToLower(strings.TrimSpace(value)) - normalized = strings.NewReplacer( - "&", " and ", - ".", " ", - ",", " ", - "-", " ", - "_", " ", - "'", " ", - "(", " ", - ")", " ", - ).Replace(normalized) - return strings.Join(strings.Fields(normalized), " ") -} - -// btnLookupIPAddrFunc matches net.Resolver lookups so tests can model DNS -// changes without relying on external name service. -type btnLookupIPAddrFunc func(context.Context, string) ([]net.IPAddr, error) - -// btnAPIDownloadOrigin records the resolved BTN API origin that download URLs -// may reuse when they would otherwise fail public-address validation. -type btnAPIDownloadOrigin struct { - scheme string - host string - addrs map[netip.Addr]struct{} - lookup btnLookupIPAddrFunc -} - -// resolveBTNURLAddrs resolves a URL host to unmapped IP addresses, preserving -// literal IP hosts without DNS. -func resolveBTNURLAddrs(ctx context.Context, parsed *url.URL, lookup btnLookupIPAddrFunc) ([]netip.Addr, error) { - host := strings.TrimSpace(parsed.Hostname()) - if addr, err := netip.ParseAddr(host); err == nil { - return []netip.Addr{addr.Unmap()}, nil - } - - resolved, err := lookup(ctx, host) - if err != nil { - return nil, fmt.Errorf("resolve host %q: %w", host, err) - } - addrs := make([]netip.Addr, 0, len(resolved)) - for _, item := range resolved { - if addr, ok := netip.AddrFromSlice(item.IP); ok { - addrs = append(addrs, addr.Unmap()) - } - } - if len(addrs) == 0 { - return nil, fmt.Errorf("host %q resolved to no usable addresses", host) - } - return addrs, nil -} - -// validateBTNPublicResolvedAddrs rejects private, loopback, link-local, -// multicast, unspecified, and otherwise non-global-unicast addresses. -func validateBTNPublicResolvedAddrs(host string, addrs []netip.Addr) error { - for _, addr := range addrs { - if !addr.IsValid() || !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() { - return fmt.Errorf("host %q resolved to blocked address %q", host, addr) - } - } - return nil -} - -type btnAPITorrentSelection struct { - ID string - GroupID string -} - -// selectBTNAPITorrent returns the BTN torrent that matches the uploaded -// release. It prefers an exact release-name match inside the uploaded group and -// only accepts a group-only match when that group has a single candidate. -func selectBTNAPITorrent(torrents map[string]map[string]any, releaseName string, groupID string) btnAPITorrentSelection { - expectedRelease := normalizeBTNAPIMatchValue(releaseName) - expectedGroup := strings.TrimSpace(groupID) - - ids := make([]string, 0, len(torrents)) - for id := range torrents { - if trimmed := strings.TrimSpace(id); trimmed != "" { - ids = append(ids, trimmed) - } - } - sortBTNAPITorrentIDs(ids) - - groupMatches := make([]string, 0, len(ids)) - for _, id := range ids { - torrentData := torrents[id] - if expectedGroup != "" { - torrentGroup := btnAPITorrentGroupID(torrentData) - if strings.TrimSpace(torrentGroup) != expectedGroup { - continue - } - } - groupMatches = append(groupMatches, id) - if expectedRelease != "" && btnAPITorrentMatchesRelease(torrentData, expectedRelease) { - return btnAPITorrentSelection{ID: id, GroupID: btnAPITorrentGroupID(torrentData)} - } - } - if len(groupMatches) == 1 { - return btnAPITorrentSelection{ID: groupMatches[0], GroupID: btnAPITorrentGroupID(torrents[groupMatches[0]])} - } - return btnAPITorrentSelection{} -} - -// btnAPITorrentGroupID extracts BTN's group id from known API field spellings. -func btnAPITorrentGroupID(torrentData map[string]any) string { - return metautil.FirstNonEmptyTrimmed( - btnAPIStringField(torrentData, "GroupID"), - btnAPIStringField(torrentData, "groupId"), - btnAPIStringField(torrentData, "GroupId"), - btnAPIStringField(torrentData, "group_id"), - ) -} - -// sortBTNAPITorrentIDs orders API result ids deterministically, newest numeric -// ids first when all compared ids are numeric. -func sortBTNAPITorrentIDs(ids []string) { - sort.Slice(ids, func(i, j int) bool { - left, leftErr := strconv.Atoi(ids[i]) - right, rightErr := strconv.Atoi(ids[j]) - if leftErr == nil && rightErr == nil { - return left > right - } - return ids[i] > ids[j] - }) -} - -// btnAPITorrentMatchesRelease reports whether a known BTN API name field is an -// exact normalized match for the release name we uploaded. -func btnAPITorrentMatchesRelease(torrentData map[string]any, expectedRelease string) bool { - for _, field := range []string{"ReleaseName", "releaseName", "TorrentName", "torrentName", "Name", "name", "Release", "release"} { - if normalizeBTNAPIMatchValue(btnAPIStringField(torrentData, field)) == expectedRelease { - return true - } - } - return false -} - -// btnAPIStringField returns an API field as a trimmed string. Missing or null -// fields produce an empty value; numeric fields keep their decimal form. -func btnAPIStringField(data map[string]any, field string) string { - if data == nil { - return "" - } - value, ok := data[field] - if !ok || value == nil { - return "" - } - switch typed := value.(type) { - case string: - return typed - case json.Number: - return typed.String() - case float64: - if typed == float64(int64(typed)) { - return strconv.FormatInt(int64(typed), 10) - } - return strconv.FormatFloat(typed, 'f', -1, 64) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - default: - return strings.TrimSpace(fmt.Sprint(value)) - } -} - -// normalizeBTNAPIMatchValue canonicalizes BTN API comparison values for exact, -// case-insensitive release-name matching. -func normalizeBTNAPIMatchValue(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -// validateBTNAPIDownloadURL applies public-address validation to a BTN API -// DownloadURL, but permits a same-origin private URL when the caller explicitly -// configured the API endpoint to that pinned private address. The pinned-origin -// exception keeps local test servers usable without allowing arbitrary private -// redirects or same-host DNS rebinding. -func validateBTNAPIDownloadURL(ctx context.Context, apiURL string, rawURL string) error { - origin, err := newBTNAPIDownloadOrigin(ctx, apiURL) - if err != nil { - return err - } - return origin.validateDownloadURL(ctx, rawURL) -} - -func newBTNAPIDownloadOrigin(ctx context.Context, apiURL string) (*btnAPIDownloadOrigin, error) { - return newBTNAPIDownloadOriginWithLookup(ctx, apiURL, net.DefaultResolver.LookupIPAddr) -} - -// newBTNAPIDownloadOriginWithLookup parses and pins the API URL's scheme, host, -// and current resolved addresses for later download redirect validation. -func newBTNAPIDownloadOriginWithLookup(ctx context.Context, apiURL string, lookup btnLookupIPAddrFunc) (*btnAPIDownloadOrigin, error) { - parsed, err := url.Parse(strings.TrimSpace(apiURL)) - if err != nil { - return nil, fmt.Errorf("parse url: %w", err) - } - scheme := strings.ToLower(parsed.Scheme) - if scheme != "http" && scheme != "https" { - return nil, fmt.Errorf("unsupported scheme %q", parsed.Scheme) - } - if strings.TrimSpace(parsed.Hostname()) == "" { - return nil, errors.New("missing host") - } - if strings.Contains(parsed.Hostname(), "%") { - return nil, fmt.Errorf("blocked private host %q", parsed.Hostname()) - } - addrs, err := resolveBTNURLAddrs(ctx, parsed, lookup) - if err != nil { - return nil, err - } - pinned := make(map[netip.Addr]struct{}, len(addrs)) - for _, addr := range addrs { - pinned[addr] = struct{}{} - } - return &btnAPIDownloadOrigin{ - scheme: scheme, - host: parsed.Host, - addrs: pinned, - lookup: lookup, - }, nil -} - -// validateDownloadURL accepts public HTTP(S) destinations and otherwise only -// permits same-origin destinations that still resolve to the pinned API address. -func (origin *btnAPIDownloadOrigin) validateDownloadURL(ctx context.Context, rawURL string) error { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil { - return fmt.Errorf("parse url: %w", err) - } - scheme := strings.ToLower(parsed.Scheme) - if scheme != "http" && scheme != "https" { - return fmt.Errorf("unsupported scheme %q", parsed.Scheme) - } - host := strings.TrimSpace(parsed.Hostname()) - if host == "" { - return errors.New("missing host") - } - addrs, err := resolveBTNURLAddrs(ctx, parsed, origin.lookup) - if err != nil { - return err - } - publicErr := validateBTNPublicResolvedAddrs(host, addrs) - lowerHost := strings.ToLower(host) - if publicErr == nil && lowerHost != "localhost" && !strings.HasSuffix(lowerHost, ".localhost") && !strings.Contains(lowerHost, "%") { - return nil - } - - if !origin.sameOrigin(parsed) { - if publicErr != nil { - return publicErr - } - return fmt.Errorf("blocked private host %q", host) - } - - for _, addr := range addrs { - if _, ok := origin.addrs[addr]; !ok { - return fmt.Errorf("blocked address %q not pinned to BTN API origin", addr) - } - } - - return nil -} - -// sameOrigin reports whether a URL has the pinned API origin's scheme and host. -func (origin *btnAPIDownloadOrigin) sameOrigin(parsed *url.URL) bool { - if origin == nil || parsed == nil { - return false - } - return strings.EqualFold(origin.scheme, parsed.Scheme) && - strings.EqualFold(origin.host, parsed.Host) && - origin.host != "" -} - -// checkBTNSeasonPackReservation enforces BTN's 2-hour internal season-pack -// reservation using the same canonical season source as the final upload -// payload, including TVDB/IMDb translated episode metadata. -func checkBTNSeasonPackReservation(ctx context.Context, uploadCtx uploadContext, req trackers.UploadRequest) error { - if resolveUploadType(req.Meta) != "Season" { - return nil - } - // The 2-hour reservation ONLY applies to season packs made of internal releases. - if !isBTNInternalGroup(req.Meta) { - return nil - } - - tvdbID := req.Meta.ExternalIDs.TVDBID - if tvdbID == 0 { - return nil - } - - group := strings.TrimPrefix(req.Meta.Tag, "-") - if group == "" { - return nil - } - - filter := map[string]any{ - "tvdb": strconv.Itoa(tvdbID), - "category": "Episode", - "group": group, - } - - // We only need the most recent episodes to check the 2-hour window - torrentsMap, err := btnAPISearchTorrents(ctx, uploadCtx.apiURL, uploadCtx.apiToken, filter, 50) - if err != nil { - return err - } - - seasonPrefix := "" - season, _ := resolveBTNTVSeasonEpisode(req.Meta) - if season > 0 { - seasonPrefix = fmt.Sprintf("S%02dE", season) - } - - var newestInternal time.Time - for _, t := range torrentsMap { - name, _ := t["ReleaseName"].(string) - if seasonPrefix != "" && !strings.Contains(strings.ToUpper(name), seasonPrefix) { - continue - } - - tTime := parseBTNTimestamp(t["Time"]) - if tTime.After(newestInternal) { - newestInternal = tTime - } - } - - if !newestInternal.IsZero() && time.Since(newestInternal) < 2*time.Hour { - return errors.New("trackers: BTN 2-hour reservation period for internal season packs has not expired") - } - - return nil -} - -func btnAPISearchTorrents(ctx context.Context, apiURL, apiToken string, filter map[string]any, limit int) (map[string]map[string]any, error) { - var response struct { - Result struct { - Torrents json.RawMessage `json:"torrents"` - } `json:"result"` - } - if err := callBTNAPI(ctx, apiURL, "ua-btn-upload-check", "getTorrentsSearch", []any{apiToken, filter, limit}, &response); err != nil { - return nil, err - } - - if len(response.Result.Torrents) == 0 || string(response.Result.Torrents) == "false" || string(response.Result.Torrents) == "[]" { - return nil, nil - } - - var torrentsMap map[string]map[string]any - if err := json.Unmarshal(response.Result.Torrents, &torrentsMap); err != nil { - return nil, fmt.Errorf("trackers: BTN API parse torrents search response: %s", redaction.RedactValue(err.Error(), nil)) - } - return torrentsMap, nil -} - -func parseBTNTimestamp(val any) time.Time { - var epoch int64 - switch v := val.(type) { - case string: - epoch, _ = strconv.ParseInt(v, 10, 64) - case float64: - epoch = int64(v) - } - if epoch > 0 { - return time.Unix(epoch, 0) - } - return time.Time{} -} diff --git a/internal/trackers/impl/commonhttp/commonhttp.go b/internal/trackers/impl/commonhttp/commonhttp.go index 48da3bb9..26deb0fd 100644 --- a/internal/trackers/impl/commonhttp/commonhttp.go +++ b/internal/trackers/impl/commonhttp/commonhttp.go @@ -21,7 +21,7 @@ import ( "strings" "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/paths" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/redaction" "github.com/autobrr/upbrr/internal/services/db" "github.com/autobrr/upbrr/pkg/api" @@ -365,7 +365,7 @@ func ResponseBodyPreview(body []byte, previewLimit int64) []byte { // WriteFailureArtifact stores a tracker failure response under the release temp // directory and returns the written path. It returns an empty path when dbPath or // the source path is unavailable. -func WriteFailureArtifact(meta api.PreparedMetadata, dbPath string, tracker string, name string, body []byte, ext string) (string, error) { +func WriteFailureArtifact(meta api.UploadSubject, dbPath string, tracker string, name string, body []byte, ext string) (string, error) { if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { return "", nil } @@ -373,7 +373,7 @@ func WriteFailureArtifact(meta api.PreparedMetadata, dbPath string, tracker stri if err != nil { return "", fmt.Errorf("trackers: %w", err) } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "", fmt.Errorf("trackers: %w", err) } diff --git a/internal/trackers/impl/commonhttp/commonhttp_test.go b/internal/trackers/impl/commonhttp/commonhttp_test.go index cfc933b8..80379cd2 100644 --- a/internal/trackers/impl/commonhttp/commonhttp_test.go +++ b/internal/trackers/impl/commonhttp/commonhttp_test.go @@ -84,7 +84,7 @@ func TestWriteFailureArtifactRedactsSensitiveBody(t *testing.T) { root := t.TempDir() dbPath := filepath.Join(root, "state", "upbrr.db") - meta := api.PreparedMetadata{SourcePath: filepath.Join(root, "source.mkv")} + meta := api.UploadSubject{SourcePath: filepath.Join(root, "source.mkv")} body := []byte(`{"message":"failed","api_key":"secret-key","detail":"token=plain-secret"}`) path, err := WriteFailureArtifact(meta, dbPath, "GPW", "upload_failure", body, ".json") diff --git a/internal/trackers/impl/commonhttp/html.go b/internal/trackers/impl/commonhttp/html.go new file mode 100644 index 00000000..3419bd17 --- /dev/null +++ b/internal/trackers/impl/commonhttp/html.go @@ -0,0 +1,185 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package commonhttp + +import ( + "context" + "fmt" + "io" + "math" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" +) + +var sizePattern = regexp.MustCompile(`(?i)([0-9]+(?:\.[0-9]+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB|B)\b`) + +// GetText performs a tracker GET request and reads the full response body for +// every HTTP status. Non-success status codes are returned as data, not errors. +func GetText(ctx context.Context, client *http.Client, endpoint string, params url.Values, cookies []*http.Cookie) (int, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, "", fmt.Errorf("commonhttp: create text GET request: %w", err) + } + if len(params) > 0 { + req.URL.RawQuery = params.Encode() + } + req.Header.Set("User-Agent", "upbrr") + ApplyCookies(req, cookies) + resp, err := client.Do(req) + if err != nil { + return 0, "", fmt.Errorf("commonhttp: text GET request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, "", fmt.Errorf("commonhttp: read text response: %w", err) + } + return resp.StatusCode, string(body), nil +} + +// GetHTML performs a tracker GET request and parses successful responses. +// Non-2xx responses return their status with a nil document and nil error. +func GetHTML(ctx context.Context, client *http.Client, endpoint string, params url.Values, cookies []*http.Cookie) (int, *xhtml.Node, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, nil, fmt.Errorf("commonhttp: create HTML GET request: %w", err) + } + if len(params) > 0 { + req.URL.RawQuery = params.Encode() + } + req.Header.Set("User-Agent", "upbrr") + ApplyCookies(req, cookies) + resp, err := client.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("commonhttp: HTML GET request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, nil, nil + } + root, err := xhtml.Parse(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("commonhttp: parse HTML response: %w", err) + } + return resp.StatusCode, root, nil +} + +// FindNodes returns matching nodes in document order. +func FindNodes(root *xhtml.Node, match func(*xhtml.Node) bool) []*xhtml.Node { + nodes := make([]*xhtml.Node, 0) + WalkNodes(root, func(node *xhtml.Node) { + if match(node) { + nodes = append(nodes, node) + } + }) + return nodes +} + +// FirstNode returns the first matching node in document order. +func FirstNode(root *xhtml.Node, match func(*xhtml.Node) bool) *xhtml.Node { + var found *xhtml.Node + var walk func(*xhtml.Node) + walk = func(node *xhtml.Node) { + if node == nil || found != nil { + return + } + if match(node) { + found = node + return + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(root) + return found +} + +// WalkNodes visits an HTML tree in document order. +func WalkNodes(root *xhtml.Node, visit func(*xhtml.Node)) { + if root == nil { + return + } + visit(root) + for child := root.FirstChild; child != nil; child = child.NextSibling { + WalkNodes(child, visit) + } +} + +// Attr returns a trimmed HTML attribute value. +func Attr(node *xhtml.Node, key string) string { + if node == nil { + return "" + } + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, key) { + return strings.TrimSpace(attr.Val) + } + } + return "" +} + +// HasClass reports whether an HTML node has a class token. +func HasClass(node *xhtml.Node, className string) bool { + for value := range strings.FieldsSeq(Attr(node, "class")) { + if strings.EqualFold(value, strings.TrimSpace(className)) { + return true + } + } + return false +} + +// NodeText returns concatenated descendant text. +func NodeText(node *xhtml.Node) string { + if node == nil { + return "" + } + if node.Type == xhtml.TextNode { + return node.Data + } + var builder strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + builder.WriteString(NodeText(child)) + } + return builder.String() +} + +// AbsoluteURL resolves a possibly relative tracker link. +func AbsoluteURL(baseURL, value string) string { + base, baseErr := url.Parse(strings.TrimSpace(baseURL)) + ref, refErr := url.Parse(strings.TrimSpace(value)) + if baseErr == nil && refErr == nil { + return base.ResolveReference(ref).String() + } + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(value, "/") +} + +// ParseSizeBytes parses decimal and binary byte units. +func ParseSizeBytes(value string) (int64, bool) { + match := sizePattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) != 3 { + return 0, false + } + amount, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, false + } + multipliers := map[string]float64{ + "b": 1, + "kb": 1e3, + "mb": 1e6, + "gb": 1e9, + "tb": 1e12, + "kib": 1024, + "mib": 1024 * 1024, + "gib": 1024 * 1024 * 1024, + "tib": 1024 * 1024 * 1024 * 1024, + } + return int64(math.Round(amount * multipliers[strings.ToLower(match[2])])), true +} diff --git a/internal/trackers/impl/commonhttp/upload_executor.go b/internal/trackers/impl/commonhttp/upload_executor.go new file mode 100644 index 00000000..323124b5 --- /dev/null +++ b/internal/trackers/impl/commonhttp/upload_executor.go @@ -0,0 +1,153 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package commonhttp + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/redaction" +) + +const defaultSuccessBodyBytes int64 = 1024 * 1024 + +// SuccessBodyPolicy selects bounded or protocol-required full reads for a +// successful tracker response. +type SuccessBodyPolicy uint8 + +const ( + // BoundedSuccessBody rejects successful response bodies above the configured limit. + BoundedSuccessBody SuccessBodyPolicy = iota + // FullSuccessBody permits a full successful response read for protocols that return artifacts inline. + FullSuccessBody +) + +// UploadExecutionOptions controls response classification and body bounds. +type UploadExecutionOptions struct { + Tracker string + SuccessStatus func(int) bool + SuccessBody SuccessBodyPolicy + SuccessBodyLimit int64 + PreviewLimit int64 +} + +// UploadExecution contains one closed HTTP response projected for tracker-local +// parsing. Failure Body and Preview are bounded and redacted; successful Body +// retains parser input while Preview remains bounded and redacted. +type UploadExecution struct { + StatusCode int + Header http.Header + Body []byte + Preview []byte + FinalURL string + Success bool +} + +// ExecuteUpload performs one prepared request and closes its response. It +// rejects oversized success bodies unless full reads are explicitly selected, +// clones response headers, and redacts the final URL and diagnostic previews. +func ExecuteUpload(client *http.Client, req *http.Request, options UploadExecutionOptions) (UploadExecution, error) { + if client == nil { + return UploadExecution{}, errors.New("tracker upload client is nil") + } + if req == nil { + return UploadExecution{}, errors.New("tracker upload request is nil") + } + tracker := strings.ToUpper(strings.TrimSpace(options.Tracker)) + if tracker == "" { + tracker = "TRACKER" + } + successStatus := options.SuccessStatus + if successStatus == nil { + successStatus = func(status int) bool { return status >= http.StatusOK && status < http.StatusMultipleChoices } + } + previewLimit := options.PreviewLimit + if previewLimit <= 0 { + previewLimit = DefaultResponsePreviewBytes + } + + // The caller constructs requests from tracker-profile endpoints; this helper + // deliberately accepts an injected client/request pair for redirects and tests. + resp, err := client.Do(req) //nolint:gosec // Tracker-owned endpoint request, not user-selected SSRF input. + if err != nil { + return UploadExecution{}, fmt.Errorf("trackers: %s upload request: %w", tracker, safeWrappedError(err)) + } + success := successStatus(resp.StatusCode) + body, readErr := readExecutedUploadBody(resp.Body, success, options.SuccessBody, options.SuccessBodyLimit, previewLimit) + closeErr := resp.Body.Close() + if readErr != nil || closeErr != nil { + return UploadExecution{}, fmt.Errorf("trackers: %s read upload response: %w", tracker, errors.Join(readErr, closeErr)) + } + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = redaction.RedactValue(resp.Request.URL.String(), nil) + } + preview := ResponseBodyPreview(body, previewLimit) + preview = []byte(redaction.RedactValue(string(preview), nil)) + if !success { + body = append([]byte(nil), preview...) + } + return UploadExecution{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: body, + Preview: preview, + FinalURL: finalURL, + Success: success, + }, nil +} + +func readExecutedUploadBody( + body io.Reader, + success bool, + policy SuccessBodyPolicy, + successLimit int64, + previewLimit int64, +) ([]byte, error) { + if !success { + payload, err := io.ReadAll(io.LimitReader(body, previewLimit)) + if err != nil { + return nil, fmt.Errorf("read bounded upload failure response: %w", err) + } + return payload, nil + } + if policy == FullSuccessBody { + payload, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("read full upload success response: %w", err) + } + return payload, nil + } + if successLimit <= 0 { + successLimit = defaultSuccessBodyBytes + } + payload, err := io.ReadAll(io.LimitReader(body, successLimit+1)) + if err != nil { + return nil, fmt.Errorf("read bounded upload success response: %w", err) + } + if int64(len(payload)) > successLimit { + return nil, fmt.Errorf("successful upload response exceeds %d bytes", successLimit) + } + return payload, nil +} + +type wrappedSafeError struct { + message string + cause error +} + +func (e wrappedSafeError) Error() string { return e.message } + +func (e wrappedSafeError) Unwrap() error { return e.cause } + +func safeWrappedError(err error) error { + if err == nil { + return nil + } + return wrappedSafeError{message: redaction.RedactValue(err.Error(), nil), cause: err} +} diff --git a/internal/trackers/impl/commonhttp/upload_executor_test.go b/internal/trackers/impl/commonhttp/upload_executor_test.go new file mode 100644 index 00000000..3756ef9e --- /dev/null +++ b/internal/trackers/impl/commonhttp/upload_executor_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package commonhttp + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type uploadRoundTripper func(*http.Request) (*http.Response, error) + +func (roundTrip uploadRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return roundTrip(req) +} + +func TestExecuteUploadBoundsAndRedactsFailure(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: uploadRoundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`api_key=secret-value ` + strings.Repeat("x", 128))), + Request: req, + }, nil + })} + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://tracker.invalid/upload", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + result, err := ExecuteUpload(client, req, UploadExecutionOptions{Tracker: "DC", PreviewLimit: 32}) + if err != nil { + t.Fatalf("execute upload: %v", err) + } + if result.Success || len(result.Body) > 32 || strings.Contains(string(result.Preview), "secret-value") { + t.Fatalf("unsafe failure projection: %#v", result) + } +} + +func TestExecuteUploadRejectsOversizedSuccessUnlessFullBodyIsExplicit(t *testing.T) { + t.Parallel() + + newClient := func() *http.Client { + return &http.Client{Transport: uploadRoundTripper(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("artifact")), + Request: req, + }, nil + })} + } + newRequest := func() *http.Request { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://tracker.invalid/upload", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + return req + } + if _, err := ExecuteUpload(newClient(), newRequest(), UploadExecutionOptions{Tracker: "CZT", SuccessBodyLimit: 4}); err == nil { + t.Fatal("expected bounded success body failure") + } + result, err := ExecuteUpload(newClient(), newRequest(), UploadExecutionOptions{Tracker: "CZT", SuccessBody: FullSuccessBody}) + if err != nil { + t.Fatalf("execute full-body upload: %v", err) + } + if string(result.Body) != "artifact" { + t.Fatalf("success body = %q", result.Body) + } +} diff --git a/internal/trackers/impl/czt/definition.go b/internal/trackers/impl/czt/definition.go deleted file mode 100644 index 9debe629..00000000 --- a/internal/trackers/impl/czt/definition.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package czt - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -// New returns the CZTeam tracker definition used by the tracker registry. -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "CZT" } - -// Upload submits a CZTeam upload request and returns the persisted registered -// torrent artifact on tracker success. -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -// BuildUploadDryRun builds the CZTeam multipart payload preview without -// sending it to the tracker. -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -// BuildDescription renders the CZTeam user description, preferring caller -// supplied description assets and resolving assets only as a fallback. -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - resolved, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err == nil { - assets = resolved - } - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: descGroup, Description: description}, nil -} diff --git a/internal/trackers/impl/czt/upload.go b/internal/trackers/impl/czt/upload.go deleted file mode 100644 index b5ba2e3c..00000000 --- a/internal/trackers/impl/czt/upload.go +++ /dev/null @@ -1,1081 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -// Package czt implements uploads to CZTeam (CZT) via its dedicated JSON -// endpoint takeupload_api.php. -// -// Unlike most impls in this repo CZTeam is not a UNIT3D site and does not need a -// cookie jar: the user's passkey authenticates the multipart POST. The endpoint -// returns the registered .torrent inline as base64, already personalized with -// the uploader's announce passkey and source=CzT. -package czt - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "maps" - "net/http" - "net/url" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/anacrolix/torrent/metainfo" - - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/redaction" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - trackerName = "CZT" - descGroup = "czt" - defaultBaseURL = "https://czteam.me" - uploadPath = "/takeupload_api.php" - uploadTimeout = 120 * time.Second - defaultCZTPostCancelGrace = 5 * time.Second - cztUploadDecodeFieldBytes = 512 -) - -var cztPostCancelGrace atomic.Int64 - -func init() { - cztPostCancelGrace.Store(int64(defaultCZTPostCancelGrace)) -} - -var ( - newCZTUploadHTTPClient = func() *http.Client { - return &http.Client{Timeout: uploadTimeout} - } - cztBaseURL = defaultBaseURL -) - -// uploadResponse mirrors the JSON returned by takeupload_api.php. A 201 carries -// the full set; a 409 duplicate still returns id/name/download_url/torrent_b64. -type uploadResponse struct { - ID int `json:"id"` - Name string `json:"name"` - InfoHash string `json:"infohash"` - DownloadURL string `json:"download_url"` - TorrentB64 string `json:"torrent_b64"` - Error string `json:"error"` -} - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - files []commonhttp.FileField - endpoint string - baseURL string - questionnaire *api.TrackerQuestionnaire -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req, true) - if err != nil { - return api.UploadSummary{}, err - } - - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, state.files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - if err := ctx.Err(); err != nil { - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) - } - - httpReq, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodPost, state.endpoint, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: CZT build upload request: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - - // Once the CZT upload POST is sent, cancellation can race with an - // irreversible remote 201. Give the tracker a short grace to return that - // result, but do not wait for the full client timeout after caller cancel. - postCtx, startPost, cancelPost := newCZTPostRequestContext(ctx) - defer cancelPost() - client := cztUploadHTTPClientWithStartHook(newCZTUploadHTTPClient(), startPost) - resp, err := client.Do(httpReq.WithContext(postCtx)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: CZT upload request: %w", err) - } - defer resp.Body.Close() - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusCreated, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: CZT read upload response: %w", err) - } - if resp.StatusCode != http.StatusCreated { - if err := ctx.Err(); err != nil { - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) - } - } - - // Only a fresh 201 with a torrent id is a successful upload. A 409 means the - // release name already exists; surface it as an error (the response still - // carries the existing torrent for callers who want to cross-seed). - if resp.StatusCode != http.StatusCreated { - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName, "upload_failure", responsePreview, ".json") - return api.UploadSummary{}, commonhttp.UploadHTTPError(trackerName, resp.StatusCode, responsePreview) - } - - torrentIDValue, err := parseCZTUploadID(responseBody) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: CZT parse upload response id: %w", err) - } - if torrentIDValue <= 0 { - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName, "upload_failure", responsePreview, ".json") - return api.UploadSummary{}, commonhttp.UploadHTTPError(trackerName, resp.StatusCode, responsePreview) - } - - torrentID := strconv.Itoa(torrentIDValue) - torrentURL := state.baseURL + "/details.php?id=" + torrentID - summary := api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ - Tracker: trackerName, - TorrentID: torrentID, - TorrentURL: torrentURL, - }}} - - parsed, err := parseCZTUploadResponse(responseBody) - if err != nil { - warnCZTLocalUploadFailure(req.Logger, "parse upload response", err) - } - - if strings.TrimSpace(parsed.DownloadURL) != "" { - downloadURL, err := joinCZTURL(state.baseURL, parsed.DownloadURL) - if err != nil { - warnCZTLocalUploadFailure(req.Logger, "upload response download_url", err) - } else { - summary.UploadedTorrents[0].DownloadURL = downloadURL - } - } - - if err := ctx.Err(); err != nil { - warnCZTLocalUploadFailure(req.Logger, "post-success cancellation", err) - return finalizeCZTUploadSummary(summary), nil - } - - // The endpoint returns the registered .torrent inline (base64), already - // personalized with the uploader's announce passkey and source=CzT, so we - // persist that directly rather than re-deriving an announce URL. - artifactPath, err := persistReturnedTorrent(req, parsed.TorrentB64) - if err != nil { - warnCZTLocalUploadFailure(req.Logger, "persist returned torrent", err) - return finalizeCZTUploadSummary(summary), nil - } - if strings.TrimSpace(artifactPath) != "" { - summary.UploadedTorrents[0].TorrentPath = artifactPath - } - - if err := ctx.Err(); err != nil { - warnCZTLocalUploadFailure(req.Logger, "post-success cancellation", err) - return finalizeCZTUploadSummary(summary), nil - } - - return finalizeCZTUploadSummary(summary), nil -} - -func warnCZTLocalUploadFailure(logger api.Logger, step string, err error) { - if logger == nil || err == nil { - return - } - logger.Warnf("trackers: CZT upload completed remotely but local %s failed: %v", step, err) -} - -// newCZTPostRequestContext lets an in-flight POST outlive caller cancellation -// for a short grace period so an irreversible 201 response can still be -// accounted for, then cancels the request before the full client timeout. -func newCZTPostRequestContext(ctx context.Context) (context.Context, func() error, context.CancelFunc) { - postCtx, cancelPost := context.WithCancel(context.WithoutCancel(ctx)) - startPost := func() error { return nil } - if ctx.Done() == nil { - return postCtx, startPost, cancelPost - } - - started := make(chan struct{}) - done := make(chan struct{}) - var startedFlag atomic.Bool - var startOnce sync.Once - var cancelOnce sync.Once - startPost = func() error { - if startedFlag.Load() { - return nil - } - if err := ctx.Err(); err != nil { - cancelPost() - return fmt.Errorf("context canceled: %w", err) - } - startOnce.Do(func() { - startedFlag.Store(true) - close(started) - }) - return nil - } - - waitGrace := func() { - timer := time.NewTimer(time.Duration(cztPostCancelGrace.Load())) - defer timer.Stop() - select { - case <-timer.C: - cancelPost() - case <-done: - } - } - - go func() { - select { - case <-started: - select { - case <-ctx.Done(): - waitGrace() - case <-done: - } - case <-ctx.Done(): - select { - case <-started: - waitGrace() - default: - cancelPost() - } - case <-done: - } - }() - - return postCtx, startPost, func() { - cancelOnce.Do(func() { - close(done) - cancelPost() - }) - } -} - -type cztPostStartTransport struct { - base http.RoundTripper - start func() error -} - -// RoundTrip records the exact start of the irreversible CZT POST and rejects -// the request if the caller canceled before the transport begins sending it. -func (t cztPostStartTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if err := t.start(); err != nil { - return nil, fmt.Errorf("start CZT upload request: %w", err) - } - resp, err := t.base.RoundTrip(req) - if err != nil { - return nil, fmt.Errorf("CZT upload round trip: %w", err) - } - return resp, nil -} - -func cztUploadHTTPClientWithStartHook(client *http.Client, start func() error) *http.Client { - if client == nil { - client = &http.Client{Timeout: uploadTimeout} - } - base := client.Transport - if base == nil { - base = http.DefaultTransport - } - out := *client - out.Transport = cztPostStartTransport{base: base, start: start} - return &out -} - -// finalizeCZTUploadSummary drops uploaded rows that cannot be injected or -// downloaded locally while preserving the accepted remote upload count. -func finalizeCZTUploadSummary(summary api.UploadSummary) api.UploadSummary { - if len(summary.UploadedTorrents) == 0 { - return summary - } - out := summary.UploadedTorrents[:0] - for _, uploaded := range summary.UploadedTorrents { - if strings.TrimSpace(uploaded.TorrentPath) != "" || strings.TrimSpace(uploaded.DownloadURL) != "" { - out = append(out, uploaded) - } - } - summary.UploadedTorrents = out - return summary -} - -func parseCZTUploadID(body []byte) (int, error) { - var fields map[string]json.RawMessage - if err := json.Unmarshal(body, &fields); err != nil { - return 0, redactCZTUploadDecodeError("decode CZT upload response fields", err) - } - var id int - if err := json.Unmarshal(fields["id"], &id); err != nil { - return 0, redactCZTUploadDecodeError("decode CZT upload response id", err) - } - return id, nil -} - -func parseCZTUploadResponse(body []byte) (uploadResponse, error) { - var parsed uploadResponse - var fields map[string]json.RawMessage - if err := json.Unmarshal(body, &fields); err != nil { - return parsed, redactCZTUploadDecodeError("decode CZT upload response fields", err) - } - if err := json.Unmarshal(fields["id"], &parsed.ID); err != nil { - return parsed, redactCZTUploadDecodeError("decode CZT upload response id", err) - } - var fieldErrs []error - parseOptionalStringField(fields, "name", &parsed.Name, &fieldErrs) - parseOptionalStringField(fields, "infohash", &parsed.InfoHash, &fieldErrs) - parseOptionalStringField(fields, "download_url", &parsed.DownloadURL, &fieldErrs) - parseOptionalStringField(fields, "torrent_b64", &parsed.TorrentB64, &fieldErrs) - parseOptionalStringField(fields, "error", &parsed.Error, &fieldErrs) - return parsed, errors.Join(fieldErrs...) -} - -// redactCZTUploadDecodeError preserves parse context while removing any -// secret-bearing fragments that encoding/json may echo from the response body. -func redactCZTUploadDecodeError(context string, err error) error { - return fmt.Errorf("%s: %s", context, redaction.RedactValue(err.Error(), nil)) -} - -func parseOptionalStringField(fields map[string]json.RawMessage, name string, dest *string, errs *[]error) { - raw := fields[name] - if len(raw) == 0 { - return - } - if err := json.Unmarshal(raw, dest); err != nil { - preview := string(raw) - if len(preview) > cztUploadDecodeFieldBytes { - preview = preview[:cztUploadDecodeFieldBytes] + "..." - } - *errs = append(*errs, fmt.Errorf("%s: %s", name, redaction.RedactValue(fmt.Sprintf("value=%s: %s", preview, err.Error()), nil))) - } -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req, false) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if missingRequiredCategory(state) { - status = "blocked" - message = "answer the category questionnaire to continue" - } - return api.TrackerDryRunEntry{ - Tracker: trackerName, - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: descGroup, - Description: state.description, - Endpoint: state.endpoint, - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest, requireCategory bool) (uploadState, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - - assets := uploadDescriptionAssets(ctx, req) - - // CZTeam stores two description fields separately: `descr` holds the raw - // MediaInfo/BDInfo dump, and `user_descr` holds the free-form BBCode body - // (user notes + screenshot images). - mediaInfo := buildMediaInfo(req) - userDescr := buildDescription(req, assets) - releaseName := resolveName(req.Meta) - baseURL := resolveBaseURL() - passkey := strings.TrimSpace(req.TrackerConfig.Passkey) - if passkey == "" { - return uploadState{}, errors.New("trackers: CZT missing passkey") - } - - category, err := resolveCategory(req.Meta) - if err != nil { - if requireCategory { - return uploadState{}, err - } - category = "" - } - - fields := map[string]string{ - "name": releaseName, - } - if category != "" { - fields["category"] = category - } - if strings.TrimSpace(mediaInfo) != "" { - fields["descr"] = mediaInfo - } - if strings.TrimSpace(userDescr) != "" { - fields["user_descr"] = userDescr - } - if imdb := imdbID(req.Meta); imdb != "" { - fields["imdb_id"] = imdb - } - // resolution/codec/container/source are validated server-side against the - // tracker's allowed value set; unknown values are dropped, not rejected. - if res := strings.TrimSpace(req.Meta.Release.Resolution); res != "" { - fields["resolution"] = res - } - if codec := firstCodec(req.Meta); codec != "" { - fields["codec"] = codec - } - if container := strings.TrimSpace(req.Meta.Container); container != "" { - fields["container"] = container - } - if source := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(req.Meta.Source, req.Meta.Release.Source)); source != "" { - fields["source"] = source - } - fields["passkey"] = passkey - - return uploadState{ - torrentPath: torrentPath, - description: userDescr, - releaseName: releaseName, - fields: fields, - files: []commonhttp.FileField{{ - FieldName: "file", - FileName: releaseName + ".torrent", - Path: torrentPath, - }}, - endpoint: baseURL + uploadPath, - baseURL: baseURL, - questionnaire: categoryQuestionnaire(req.Meta), - }, nil -} - -// uploadDescriptionAssets uses caller-prepared assets when available, falling -// back to local resolution and an empty asset set on resolution failure. -func uploadDescriptionAssets(ctx context.Context, req trackers.UploadRequest) trackers.DescriptionAssets { - if req.Assets != nil { - return *req.Assets - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - return trackers.DescriptionAssets{} - } - return assets -} - -// persistReturnedTorrent decodes the tracker-returned base64 torrent, verifies -// it parses as metainfo, and writes the registered torrent artifact with user -// read/write permissions only. Cleanup errors after replacement return the -// artifact path with a non-nil error so callers can avoid reporting it as -// successfully persisted. -func persistReturnedTorrent(req trackers.UploadRequest, b64 string) (string, error) { - if strings.TrimSpace(b64) == "" { - return "", errors.New("empty torrent_b64") - } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) - if err != nil { - return "", fmt.Errorf("decode torrent_b64: %w", err) - } - if len(decoded) == 0 { - return "", errors.New("decoded torrent_b64 is empty") - } - torrentMeta, err := metainfo.Load(bytes.NewReader(decoded)) - if err != nil { - return "", fmt.Errorf("load returned torrent: %w", err) - } - if _, err := torrentMeta.UnmarshalInfo(); err != nil { - return "", fmt.Errorf("unmarshal returned torrent info: %w", err) - } - path, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName) - if err != nil { - return "", fmt.Errorf("resolve returned torrent path: %w", err) - } - if err := writeReturnedTorrent(path, decoded); err != nil { - var cleanupErr returnedTorrentCleanupError - if errors.As(err, &cleanupErr) { - return path, fmt.Errorf("write returned torrent: %w", err) - } - return "", fmt.Errorf("write returned torrent: %w", err) - } - return path, nil -} - -// writeReturnedTorrent stages returned torrent bytes in the destination -// directory before replacing the final artifact path, so a failed write does -// not truncate an existing registered torrent. -func writeReturnedTorrent(path string, data []byte) error { - dir := filepath.Dir(path) - tmpFile, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") - if err != nil { - return fmt.Errorf("create temp returned torrent: %w", err) - } - tmpPath := tmpFile.Name() - removeTemp := true - defer func() { - if removeTemp { - _ = os.Remove(tmpPath) - } - }() - - if err := tmpFile.Chmod(0o600); err != nil { - _ = tmpFile.Close() - return fmt.Errorf("chmod temp returned torrent: %w", err) - } - if _, err := tmpFile.Write(data); err != nil { - _ = tmpFile.Close() - return fmt.Errorf("write temp returned torrent: %w", err) - } - if err := tmpFile.Sync(); err != nil { - _ = tmpFile.Close() - return fmt.Errorf("sync temp returned torrent: %w", err) - } - if err := tmpFile.Close(); err != nil { - return fmt.Errorf("close temp returned torrent: %w", err) - } - if err := replaceStagedReturnedTorrent(tmpPath, path); err != nil { - return err - } - removeTemp = false - return nil -} - -// replaceStagedReturnedTorrent moves a staged torrent into place. Existing -// artifacts are first moved to a temporary backup and restored if the final -// rename fails. -func replaceStagedReturnedTorrent(tmpPath string, outputPath string) error { - info, err := os.Stat(outputPath) - if err != nil { - if os.IsNotExist(err) { - if renameErr := os.Rename(tmpPath, outputPath); renameErr != nil { - return fmt.Errorf("rename staged returned torrent into place: %w", renameErr) - } - return nil - } - return fmt.Errorf("stat returned torrent: %w", err) - } - if info.IsDir() { - return fmt.Errorf("%s is a directory", outputPath) - } - - backupPath, err := reserveReturnedTorrentBackupPath(filepath.Dir(outputPath), filepath.Base(outputPath)+".backup-*") - if err != nil { - return err - } - if err := os.Rename(outputPath, backupPath); err != nil { - _ = os.Remove(backupPath) - return fmt.Errorf("backup existing returned torrent: %w", err) - } - if err := os.Rename(tmpPath, outputPath); err != nil { - restoreErr := os.Rename(backupPath, outputPath) - if restoreErr != nil { - return errors.Join(err, fmt.Errorf("restore existing returned torrent: %w", restoreErr)) - } - return fmt.Errorf("replace existing returned torrent: %w", err) - } - if err := removeReturnedTorrentBackup(backupPath); err != nil { - return returnedTorrentCleanupError{cause: fmt.Errorf("remove returned torrent backup: %w", err)} - } - return nil -} - -type returnedTorrentCleanupError struct { - cause error -} - -func (e returnedTorrentCleanupError) Error() string { - return e.cause.Error() -} - -func (e returnedTorrentCleanupError) Unwrap() error { - return e.cause -} - -var removeReturnedTorrentBackup = os.Remove - -// reserveReturnedTorrentBackupPath reserves and releases a same-directory path -// suitable for temporarily holding the existing registered torrent. -func reserveReturnedTorrentBackupPath(dir string, pattern string) (string, error) { - file, err := os.CreateTemp(dir, pattern) - if err != nil { - return "", fmt.Errorf("create temp returned torrent backup marker: %w", err) - } - path := file.Name() - closeErr := file.Close() - removeErr := os.Remove(path) - if closeErr != nil || removeErr != nil { - return "", errors.Join(closeErr, removeErr) - } - return path, nil -} - -// buildMediaInfo returns the raw MediaInfo/BDInfo text for the CZTeam `descr` -// field. -func buildMediaInfo(req trackers.UploadRequest) string { - return strings.TrimSpace(trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, req.Meta)) -} - -// buildDescription assembles the CZTeam `user_descr` body: the (possibly -// user-edited) description text followed by a BBCode screenshot block. Kept as a -// separate function so definition.BuildDescription can drive the description -// builder UI with the same output. -func buildDescription(_ trackers.UploadRequest, assets trackers.DescriptionAssets) string { - // A "final" description is the already-assembled body (saved override or - // canonical group description) with screenshots embedded; the resolver does - // not clear assets.Screenshots here, so re-appending would duplicate them. - // Use it verbatim, matching the assets.Final convention other impls follow. - if assets.Final { - return strings.TrimSpace(assets.Description) - } - parts := make([]string, 0, 2) - if body := strings.TrimSpace(assets.Description); body != "" { - parts = append(parts, body) - } - if shots := bbcodeScreenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - return strings.TrimSpace(strings.Join(parts, "\n\n")) -} - -// bbcodeScreenshotBlock renders at most two raw screenshot image URLs. CZTeam's -// formatter accepts plain [img] tags here; linked/thumbnail URLs are ignored. -func bbcodeScreenshotBlock(images []api.ScreenshotImage) string { - parts := make([]string, 0, 2) - for _, image := range images { - raw := strings.TrimSpace(image.RawURL) - if raw == "" { - continue - } - parts = append(parts, "[img]"+raw+"[/img]") - if len(parts) == 2 { - break - } - } - return strings.Join(parts, "\n") -} - -// resolveBaseURL returns the fixed CZTeam origin used for upload, details, and -// returned download URLs. CZTeam is not a tracker family/configurable endpoint. -func resolveBaseURL() string { - if trimmed := strings.TrimRight(strings.TrimSpace(cztBaseURL), "/"); trimmed != "" { - return trimmed - } - return defaultBaseURL -} - -// joinCZTURL resolves a tracker-provided download URL against the CZTeam base -// URL, strips URL userinfo, and rejects empty, non-addressable, or -// cross-host results. -func joinCZTURL(baseURL string, rawRef string) (string, error) { - trimmedRef := strings.TrimSpace(rawRef) - if trimmedRef == "" { - return "", errors.New("empty URL") - } - base, err := url.Parse(resolveCZTURLBase(baseURL) + "/") - if err != nil { - return "", fmt.Errorf("parse base URL: %w", err) - } - if base.Scheme == "" || base.Host == "" { - return "", errors.New("base URL must be absolute") - } - ref, err := url.Parse(trimmedRef) - if err != nil { - return "", fmt.Errorf("parse URL: %w", err) - } - resolved := base.ResolveReference(ref) - if resolved.Scheme == "" || resolved.Host == "" { - return "", errors.New("resolved URL must be absolute") - } - if !strings.EqualFold(resolved.Scheme, base.Scheme) || !strings.EqualFold(resolved.Host, base.Host) { - return "", errors.New("resolved URL must stay on configured CZT host") - } - if !hasUsableCZTDownloadPath(resolved.Path) { - return "", errors.New("resolved URL has no torrent path") - } - resolved.User = nil - return resolved.String(), nil -} - -func hasUsableCZTDownloadPath(path string) bool { - trimmed := strings.TrimSpace(path) - return trimmed != "" && trimmed != "/" -} - -// resolveCZTURLBase normalizes a base URL before resolving tracker-provided -// relative download URLs against it. -func resolveCZTURLBase(baseURL string) string { - trimmed := strings.TrimSpace(baseURL) - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return strings.TrimRight(trimmed, "/") - } - parsed.Path = "" - parsed.RawPath = "" - parsed.RawQuery = "" - parsed.ForceQuery = false - parsed.Fragment = "" - parsed.User = nil - return strings.TrimRight(parsed.String(), "/") -} - -func resolveName(meta api.PreparedMetadata) string { - if name := strings.TrimSpace(meta.SceneName); name != "" { - return name - } - return strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename)) -} - -// cztCategory pairs a CZTeam categories.id with its display name. -type cztCategory struct { - id string - name string -} - -// cztCategories lists CZTeam upload categories for the upload-time override -// dropdown. upbrr auto-detects only video categories from metadata; everything -// else (software, games, music, XXX, images, docs, …) is chosen here. -var cztCategories = []cztCategory{ - {"1", "XxX"}, - {"4", "Games/PC ISO"}, - {"5", "TvEps/HD"}, - {"6", "Music/Audio"}, - {"7", "TvEps"}, - {"9", "Mobile"}, - {"12", "Games/Consoles"}, - {"19", "Movies/XviD"}, - {"20", "Movies/DVD-R"}, - {"21", "Games/PC Rips"}, - {"22", "Software"}, - {"23", "Anime"}, - {"24", "Images"}, - {"25", "Docs"}, - {"28", "Movies/DVD-RO"}, - {"29", "Movies/HD"}, - {"30", "Music/MVID"}, - {"31", "MAC"}, - {"32", "Sports"}, - {"33", "Movies/HDTV-RO"}, - {"34", "TvEps/HD-RO"}, - {"35", "Music/Lossless"}, - {"36", "Full BluRay-RO"}, - {"37", "Movies/3D"}, - {"38", "Movies-RO"}, -} - -func categoryNames() []string { - out := make([]string, 0, len(cztCategories)) - for _, c := range cztCategories { - out = append(out, c.name) - } - return out -} - -func categoryIDForName(name string) string { - name = strings.TrimSpace(name) - for _, c := range cztCategories { - if strings.EqualFold(c.name, name) { - return c.id - } - } - return "" -} - -func categoryNameForID(id string) string { - for _, c := range cztCategories { - if c.id == id { - return c.name - } - } - return "" -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers[trackerName] -} - -// categoryQuestionnaire offers a (non-blocking) category dropdown pre-filled -// with the auto-detected category, so the user can override it for content -// upbrr can't classify from video metadata. -func categoryQuestionnaire(meta api.PreparedMetadata) *api.TrackerQuestionnaire { - auto := autoCategory(meta) - return &api.TrackerQuestionnaire{ - Tracker: trackerName, - Fields: []api.TrackerQuestionnaireField{{ - Key: "category", - Label: "Category", - Kind: "select", - Options: categoryNames(), - Value: categoryNameForID(auto), - Help: "Auto-detected from video metadata. Override for software, games, music, XXX, etc.", - Required: auto == "", - }}, - } -} - -// resolveCategory returns the CZTeam category id: an explicit questionnaire -// override when the user picked one, otherwise the auto-detected video category. -func resolveCategory(meta api.PreparedMetadata) (string, error) { - if id := resolveQuestionnaireCategory(questionnaireAnswers(meta)["category"]); id != "" { - return id, nil - } - if id := autoCategory(meta); id != "" { - return id, nil - } - return "", errors.New("trackers: CZT category requires explicit questionnaire selection for non-video content") -} - -func resolveQuestionnaireCategory(value string) string { - if id := categoryIDForName(value); id != "" { - return id - } - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "" - } - for _, c := range cztCategories { - if c.id == trimmed { - return c.id - } - } - return "" -} - -// autoCategory maps prepared metadata to a CZTeam numeric categories.id when -// metadata supports automatic classification. Unknown non-video content returns -// empty so callers can require an explicit questionnaire category instead of -// falling back to a movie bucket. -func autoCategory(meta api.PreparedMetadata) string { - if id, ok := nonVideoCategory(meta); ok { - return id - } - - ro := hasRomanianSubs(meta) - hd := isHD(meta.Release.Resolution) - - switch { - case meta.Anime: - return "23" // Anime - case isTV(meta): - if hd && ro { - return "34" // TvEps/HD-RO - } - if hd { - return "5" // TvEps/HD - } - return "7" // TvEps (no SD-RO TV category exists) - } - - // Movies. - src := strings.ToUpper(metautil.FirstNonEmptyTrimmed(meta.Source, meta.Release.Source)) - isDVD := strings.Contains(src, "DVD") || strings.EqualFold(meta.DiscType, "DVD") || strings.EqualFold(meta.Type, "DVDRIP") - isFullBluRay := strings.EqualFold(meta.DiscType, "BDMV") || - (strings.EqualFold(meta.Type, "REMUX") && strings.Contains(src, "BLURAY")) - if !hasMovieCategoryEvidence(meta, src) && !isDVD && !isFullBluRay { - return "" - } - - if ro { - switch { - case isFullBluRay: - return "36" // Full BluRay-RO - case isDVD: - return "28" // Movies/DVD-RO - case hd: - return "33" // Movies/HDTV-RO - default: - return "38" // Movies-RO - } - } - switch { - case isDVD: - return "20" // Movies/DVD-R - case hd: - return "29" // Movies/HD - case hasCodec(meta, "XviD"): - return "19" // Movies/XviD - default: - return "29" // default to Movies/HD when movie evidence exists - } -} - -func hasMovieCategoryEvidence(meta api.PreparedMetadata, upperSource string) bool { - for _, hint := range []string{meta.ExternalIDs.Category, meta.MediaInfoCategory, meta.Release.Category} { - normalized := normalizeCategoryHint(hint) - if isVideoCategoryHint(normalized) { - return true - } - } - return strings.TrimSpace(upperSource) != "" || - strings.TrimSpace(meta.Release.Resolution) != "" || - firstCodec(meta) != "" || - strings.TrimSpace(meta.DiscType) != "" || - strings.TrimSpace(meta.Type) != "" -} - -// nonVideoCategory classifies explicit non-video category hints. Video hints are -// ignored here so mixed hints can still use video classification; the boolean -// reports whether a non-video hint was found even when it still requires a -// manual questionnaire answer. -func nonVideoCategory(meta api.PreparedMetadata) (string, bool) { - hints := []string{ - meta.ExternalIDs.Category, - meta.MediaInfoCategory, - meta.Release.Category, - } - for _, hint := range hints { - normalized := normalizeCategoryHint(hint) - if normalized == "" { - continue - } - if isMusicVideoCategoryHint(normalized) { - return "30", true - } - switch { - case hasCategoryHintToken(normalized, "anime"): - return "23", true - case hasCategoryHintToken(normalized, "xxx", "adult"): - return "1", true - case isVideoCategoryHint(normalized): - continue - case hasCategoryHintToken(normalized, "console", "consoles"): - return "12", true - case hasCategoryHintToken(normalized, "game", "games"): - return "4", true - case hasCategoryHintToken(normalized, "lossless", "flac"): - return "35", true - case hasCategoryHintToken(normalized, "music", "audio"): - return "6", true - case hasCategoryHintToken(normalized, "software", "app", "apps", "application", "applications"): - return "22", true - case hasCategoryHintToken(normalized, "mobile", "android", "ios"): - return "9", true - case hasCategoryHintToken(normalized, "image", "images", "photo", "photos"): - return "24", true - case hasCategoryHintToken(normalized, "doc", "docs", "book", "books", "ebook", "ebooks"): - return "25", true - case hasCategoryHintToken(normalized, "mac"): - return "31", true - case hasCategoryHintToken(normalized, "sport", "sports"): - return "32", true - default: - return "", true - } - } - return "", false -} - -func missingRequiredCategory(state uploadState) bool { - if state.questionnaire == nil { - return false - } - if strings.TrimSpace(state.fields["category"]) != "" { - return false - } - for _, field := range state.questionnaire.Fields { - if field.Key == "category" && field.Required { - return true - } - } - return false -} - -func normalizeCategoryHint(value string) string { - replacer := strings.NewReplacer("-", " ", "_", " ", ".", " ", "/", " ", "\\", " ", ":", " ") - return strings.ToLower(strings.TrimSpace(replacer.Replace(value))) -} - -func isMusicVideoCategoryHint(value string) bool { - fields := strings.Fields(value) - for i, field := range fields { - if field == "mvid" { - return true - } - if field == "music" && i+1 < len(fields) && fields[i+1] == "video" { - return true - } - } - compact := strings.Join(fields, "") - return strings.Contains(compact, "musicvideo") || strings.Contains(compact, "mvid") -} - -func isVideoCategoryHint(value string) bool { - return hasCategoryHintToken(value, "movie", "movies", "film", "films", "tv", "tveps", "episode", "episodes", "anime", "video", "videos", "documentary", "documentaries") -} - -func hasCategoryHintToken(value string, want ...string) bool { - for field := range strings.FieldsSeq(value) { - if slices.Contains(want, field) { - return true - } - } - return false -} - -func hasRomanianSubs(meta api.PreparedMetadata) bool { - for _, s := range meta.SubtitleLanguages { - v := strings.ToLower(strings.TrimSpace(s)) - if v == "ro" || v == "rum" || v == "ron" || strings.HasPrefix(v, "roman") { - return true - } - } - return false -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} - -func isHD(res string) bool { - res = strings.TrimSpace(res) - for _, prefix := range []string{"720", "1080", "2160", "4320"} { - if strings.HasPrefix(res, prefix) { - return true - } - } - return false -} - -func hasCodec(meta api.PreparedMetadata, want string) bool { - for _, c := range meta.Release.Codec { - if strings.EqualFold(strings.TrimSpace(c), want) { - return true - } - } - return false -} - -func firstCodec(meta api.PreparedMetadata) string { - for _, c := range meta.Release.Codec { - if v := strings.TrimSpace(c); v != "" { - return v - } - } - return "" -} - -func imdbID(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("tt%07d", meta.ExternalIDs.IMDBID) -} - -func cloneFields(input map[string]string) map[string]string { - out := make(map[string]string, len(input)) - maps.Copy(out, input) - return out -} diff --git a/internal/trackers/impl/czt/upload_test.go b/internal/trackers/impl/czt/upload_test.go deleted file mode 100644 index 6c251c16..00000000 --- a/internal/trackers/impl/czt/upload_test.go +++ /dev/null @@ -1,1060 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package czt - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" - - mkbrr "github.com/autobrr/mkbrr/torrent" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestUploadSuccessPersistsReturnedTorrentAndUsesProvidedAssets(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - server.assertNoHandlerError(t) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected one uploaded torrent, got %+v", result) - } - uploaded := result.UploadedTorrents[0] - if uploaded.TorrentURL != server.URL+"/details.php?id=123" { - t.Fatalf("unexpected torrent URL: %q", uploaded.TorrentURL) - } - if uploaded.DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("unexpected download URL: %q", uploaded.DownloadURL) - } - if strings.TrimSpace(uploaded.TorrentPath) == "" { - t.Fatal("expected persisted returned torrent path") - } - payload, err := os.ReadFile(uploaded.TorrentPath) - if err != nil { - t.Fatalf("read persisted torrent: %v", err) - } - if string(payload) != string(returnedTorrent) { - t.Fatalf("persisted torrent did not match returned bytes") - } -} - -func TestUploadIgnoresStaleConfigURLAndAPIKey(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) - defer server.Close() - - req := cztUploadRequest(t, server.URL) - req.TrackerConfig.URL = "https://unused.example" - req.TrackerConfig.APIKey = "stale-api-key" - - result, err := upload(context.Background(), req) - server.assertNoHandlerError(t) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected upload success, got %+v", result) - } -} - -func TestUploadRequiresPasskey(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusCreated) - })) - defer server.Close() - - req := cztUploadRequest(t, server.URL) - req.TrackerConfig.Passkey = "" - - result, err := upload(context.Background(), req) - if err == nil { - t.Fatal("expected missing passkey error") - } - if !strings.Contains(err.Error(), "missing passkey") { - t.Fatalf("expected missing passkey error, got %v", err) - } - if requests.Load() != 0 { - t.Fatalf("expected no remote request without passkey, got %d", requests.Load()) - } - if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result, got %+v", result) - } -} - -func TestUploadTestServerReportsHandlerAssertionOnTestGoroutine(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - server := newCZTUploadTestServer(t, returnedTorrent, "/wrong-upload-path") - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - server.assertHandlerErrorContains(t, `expected upload path "/wrong-upload-path"`) - if err == nil { - t.Fatal("expected upload error") - } - if !strings.Contains(err.Error(), "status=400") { - t.Fatalf("expected HTTP 400 upload error, got %v", err) - } - if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result on handler assertion failure, got %+v", result) - } -} - -func TestUploadReportsRemoteSuccessWhenReturnedTorrentB64IsInvalid(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":"not-base64"}`)) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success without artifact failure, got %+v", result) - } - if result.UploadedTorrents[0].TorrentPath != "" { - t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) - } -} - -func TestUploadReportsRemoteSuccessWhenReturnedTorrentB64IsEmpty(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":" "}`)) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success without artifact failure, got %+v", result) - } - if result.UploadedTorrents[0].TorrentPath != "" { - t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) - } -} - -func TestUploadReportsRemoteSuccessWhenReturnedTorrentIsCorrupt(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":%q}`, - base64.StdEncoding.EncodeToString([]byte("registered-torrent")), - ) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success without artifact failure, got %+v", result) - } - if result.UploadedTorrents[0].TorrentPath != "" { - t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) - } -} - -func TestUploadReportsRemoteSuccessWhenDownloadURLIsOffsite(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"id":123,"name":"Release","download_url":"https://evil.example/download.php?id=123","torrent_b64":%q}`, - base64.StdEncoding.EncodeToString(returnedTorrent), - ) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success with artifact, got %+v", result) - } - if result.UploadedTorrents[0].DownloadURL != "" { - t.Fatalf("expected rejected download URL to be omitted, got %q", result.UploadedTorrents[0].DownloadURL) - } - if strings.TrimSpace(result.UploadedTorrents[0].TorrentPath) == "" { - t.Fatal("expected persisted returned torrent path") - } -} - -func TestUploadKeepsRemoteSuccessWhenNoInjectableArtifactExists(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"https://evil.example/download.php?id=123","torrent_b64":"not-base64"}`)) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected remote success count without injectable artifacts, got %+v", result) - } - if len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no injectable artifact entry, got %+v", result.UploadedTorrents) - } -} - -func TestUploadResponseParseBoundaries(t *testing.T) { - tests := []struct { - name string - statusCode int - body string - want string - }{ - { - name: "malformed 201", - statusCode: http.StatusCreated, - body: `{`, - want: "parse upload response", - }, - { - name: "malformed non-201 stays http failure", - statusCode: http.StatusInternalServerError, - body: `{`, - want: "status=500", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(tt.statusCode) - _, _ = w.Write([]byte(tt.body)) - })) - defer server.Close() - - _, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err == nil { - t.Fatal("expected upload error") - } - if !strings.Contains(err.Error(), tt.want) { - t.Fatalf("expected error containing %q, got %v", tt.want, err) - } - }) - } -} - -func TestCZTUploadDecodeErrorsAreRedacted(t *testing.T) { - err := redactCZTUploadDecodeError("decode CZT upload response fields", errors.New("api_key=secret-passkey Cookie=session-token")) - if err == nil { - t.Fatal("expected redacted error") - } - got := err.Error() - if strings.Contains(got, "secret-passkey") || strings.Contains(got, "session-token") { - t.Fatalf("expected redacted decode error") - } - if !strings.Contains(got, "decode CZT upload response fields") || !strings.Contains(got, "[REDACTED]") { - t.Fatalf("expected context and redaction marker, got %q", got) - } - - if _, err := parseCZTUploadID([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response fields") { - t.Fatalf("expected redacted fields decode path, got %v", err) - } - if _, err := parseCZTUploadID([]byte(`{"id":"bad"}`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response id") { - t.Fatalf("expected redacted id decode path, got %v", err) - } - if _, err := parseCZTUploadResponse([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response fields") { - t.Fatalf("expected redacted response fields decode path, got %v", err) - } - if _, err := parseCZTUploadResponse([]byte(`{"id":"bad"}`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response id") { - t.Fatalf("expected redacted response id decode path, got %v", err) - } - if _, err := parseCZTUploadResponse([]byte(`{"id":123,"error":{"api_key":"secret-passkey"}}`)); err == nil { - t.Fatal("expected redacted optional field decode path") - } else if got := err.Error(); !strings.Contains(got, "error") || !strings.Contains(got, "[REDACTED]") || strings.Contains(got, "secret-passkey") { - t.Fatalf("expected redacted optional field decode error, got %q", got) - } -} - -func TestUploadSuccessParsesLargeResponse(t *testing.T) { - padding := strings.Repeat("a", 70*1024) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"name":%q,"id":123,"download_url":"/download.php?id=123","torrent_b64":" "}`, - padding, - ) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success without artifact failure, got %+v", result) - } - if result.UploadedTorrents[0].TorrentID != "123" { - t.Fatalf("expected torrent ID 123, got %q", result.UploadedTorrents[0].TorrentID) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected download URL from large response, got %q", result.UploadedTorrents[0].DownloadURL) - } -} - -func TestUploadResponseBadLocalFieldPreservesRemoteSuccess(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":123,"download_url":"/download.php?id=123","torrent_b64":123}`)) - })) - defer server.Close() - - result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success without artifact, got %+v", result) - } - if result.UploadedTorrents[0].TorrentID != "123" || result.UploadedTorrents[0].TorrentPath != "" { - t.Fatalf("unexpected uploaded torrent: %+v", result.UploadedTorrents[0]) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected usable download URL despite bad local field, got %q", result.UploadedTorrents[0].DownloadURL) - } -} - -func TestUploadPreCanceledContextStopsBeforeRemoteRequest(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - result, err := upload(ctx, cztUploadRequest(t, server.URL)) - if err == nil { - t.Fatal("expected cancellation error") - } - if !strings.Contains(err.Error(), "context canceled") { - t.Fatalf("expected context cancellation error, got %v", err) - } - if requests.Load() != 0 { - t.Fatalf("expected no remote request after pre-send cancellation, got %d", requests.Load()) - } - if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result after pre-send cancellation, got %+v", result) - } -} - -func TestUploadCancellationAfterRequestBuildStopsBeforeRemoteRequest(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - originalFactory := newCZTUploadHTTPClient - newCZTUploadHTTPClient = func() *http.Client { - cancel() - return server.Client() - } - t.Cleanup(func() { - newCZTUploadHTTPClient = originalFactory - }) - - result, err := upload(ctx, cztUploadRequest(t, server.URL)) - if err == nil { - t.Fatal("expected cancellation error") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context cancellation error, got %v", err) - } - if requests.Load() != 0 { - t.Fatalf("expected no remote request after post-build cancellation, got %d", requests.Load()) - } - if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result after post-build cancellation, got %+v", result) - } -} - -func TestUploadCancellationAfterResponsePreservesRemoteSuccessWithoutPersistingArtifact(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - ctx, cancel := context.WithCancel(context.Background()) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"id":123,"name":"Release","download_url":"download.php?id=123","torrent_b64":%q}`, - base64.StdEncoding.EncodeToString(returnedTorrent), - ) - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - cancel() - })) - defer server.Close() - - req := cztUploadRequest(t, server.URL) - artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName) - if err != nil { - t.Fatalf("resolve artifact path: %v", err) - } - result, err := upload(ctx, req) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success after cancellation, got %+v", result) - } - if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { - t.Fatalf("expected download URL preserved, got %q", result.UploadedTorrents[0].DownloadURL) - } - if result.UploadedTorrents[0].TorrentPath != "" { - t.Fatalf("expected no persisted artifact path, got %q", result.UploadedTorrents[0].TorrentPath) - } - if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { - t.Fatalf("expected no persisted artifact, stat err=%v", statErr) - } -} - -func TestUploadCancellationBeforeNonCreatedResponseReturnsCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - cancel() - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"failed"}`)) - })) - defer server.Close() - - result, err := upload(ctx, cztUploadRequest(t, server.URL)) - if err == nil { - t.Fatal("expected cancellation error") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context cancellation error, got %v", err) - } - if strings.Contains(err.Error(), "status=500") { - t.Fatalf("expected cancellation before HTTP error, got %v", err) - } - if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result after non-201 cancellation, got %+v", result) - } -} - -func TestUploadCancellationDuringPostCancelsBeforeUploadTimeout(t *testing.T) { - oldGrace := cztPostCancelGrace.Swap(int64(25 * time.Millisecond)) - t.Cleanup(func() { - cztPostCancelGrace.Store(oldGrace) - }) - - requestStarted := make(chan struct{}) - releaseHandler := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - close(requestStarted) - select { - case <-r.Context().Done(): - case <-releaseHandler: - } - })) - defer server.Close() - defer close(releaseHandler) - - ctx, cancel := context.WithCancel(context.Background()) - req := cztUploadRequest(t, server.URL) - done := make(chan struct { - summary api.UploadSummary - err error - }, 1) - go func() { - summary, err := upload(ctx, req) - done <- struct { - summary api.UploadSummary - err error - }{summary: summary, err: err} - }() - - select { - case <-requestStarted: - case <-time.After(time.Second): - t.Fatal("expected remote request to start") - } - - started := time.Now() - cancel() - select { - case result := <-done: - if result.err == nil { - t.Fatal("expected cancellation error") - } - if !errors.Is(result.err, context.Canceled) { - t.Fatalf("expected context cancellation error, got %v", result.err) - } - if result.summary.Uploaded != 0 || len(result.summary.UploadedTorrents) != 0 { - t.Fatalf("expected no uploaded result after canceled POST, got %+v", result.summary) - } - case <-time.After(time.Second): - t.Fatal("expected canceled POST to return before upload timeout") - } - if elapsed := time.Since(started); elapsed >= time.Second { - t.Fatalf("expected canceled POST below upload timeout, elapsed %s", elapsed) - } -} - -func TestUploadCancellationAfterPersistPreservesArtifactSummary(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - ctx, cancel := context.WithCancel(context.Background()) - server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) - defer server.Close() - - req := cztUploadRequest(t, server.URL) - artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName) - if err != nil { - t.Fatalf("resolve artifact path: %v", err) - } - if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { - t.Fatalf("create artifact dir: %v", err) - } - if err := os.WriteFile(artifactPath, []byte("existing artifact"), 0o600); err != nil { - t.Fatalf("write existing artifact: %v", err) - } - - oldRemove := removeReturnedTorrentBackup - removeReturnedTorrentBackup = func(path string) error { - err := oldRemove(path) - if strings.Contains(filepath.Base(path), ".backup-") { - cancel() - } - return err - } - defer func() { - removeReturnedTorrentBackup = oldRemove - }() - - result, err := upload(ctx, req) - server.assertNoHandlerError(t) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { - t.Fatalf("expected remote success with artifact after cancellation, got %+v", result) - } - if result.UploadedTorrents[0].TorrentPath != artifactPath { - t.Fatalf("expected artifact path %q, got %q", artifactPath, result.UploadedTorrents[0].TorrentPath) - } -} - -func TestReplaceStagedReturnedTorrentRestoresExistingOnFailure(t *testing.T) { - tmp := t.TempDir() - outputPath := filepath.Join(tmp, "registered.torrent") - original := []byte("existing artifact") - if err := os.WriteFile(outputPath, original, 0o600); err != nil { - t.Fatalf("write existing artifact: %v", err) - } - - err := replaceStagedReturnedTorrent(filepath.Join(tmp, "missing.tmp"), outputPath) - if err == nil { - t.Fatal("expected replacement error") - } - got, readErr := os.ReadFile(outputPath) - if readErr != nil { - t.Fatalf("read restored artifact: %v", readErr) - } - if string(got) != string(original) { - t.Fatalf("expected existing artifact preserved, got %q", got) - } -} - -func TestUploadOmitsArtifactPathWhenBackupCleanupFails(t *testing.T) { - returnedTorrent := validTorrentBytes(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"id":123,"name":"Release","torrent_b64":%q}`, - base64.StdEncoding.EncodeToString(returnedTorrent), - ) - })) - defer server.Close() - - req := cztUploadRequest(t, server.URL) - artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName) - if err != nil { - t.Fatalf("resolve artifact path: %v", err) - } - if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { - t.Fatalf("create artifact dir: %v", err) - } - if err := os.WriteFile(artifactPath, []byte("existing artifact"), 0o600); err != nil { - t.Fatalf("write existing artifact: %v", err) - } - - oldRemove := removeReturnedTorrentBackup - removeReturnedTorrentBackup = func(path string) error { - if strings.Contains(filepath.Base(path), ".backup-") { - return os.ErrPermission - } - return oldRemove(path) - } - defer func() { - removeReturnedTorrentBackup = oldRemove - }() - - result, err := upload(context.Background(), req) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 || len(result.UploadedTorrents) != 0 { - t.Fatalf("expected cleanup-error artifact omitted while preserving remote success, got %+v", result) - } -} - -func TestPersistReturnedTorrentReturnsPathWhenBackupCleanupFails(t *testing.T) { - req := cztUploadRequest(t, defaultBaseURL) - artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, trackerName) - if err != nil { - t.Fatalf("resolve artifact path: %v", err) - } - if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { - t.Fatalf("create artifact dir: %v", err) - } - original := []byte("existing artifact") - if err := os.WriteFile(artifactPath, original, 0o600); err != nil { - t.Fatalf("write existing artifact: %v", err) - } - - oldRemove := removeReturnedTorrentBackup - removeReturnedTorrentBackup = func(path string) error { - if strings.Contains(filepath.Base(path), ".backup-") { - return os.ErrPermission - } - return oldRemove(path) - } - defer func() { - removeReturnedTorrentBackup = oldRemove - }() - - gotPath, err := persistReturnedTorrent(req, base64.StdEncoding.EncodeToString(validTorrentBytes(t))) - if err == nil { - t.Fatal("expected cleanup error") - } - var cleanupErr returnedTorrentCleanupError - if !errors.As(err, &cleanupErr) { - t.Fatalf("expected cleanup error, got %v", err) - } - if gotPath != artifactPath { - t.Fatalf("expected artifact path %q, got %q", artifactPath, gotPath) - } - payload, readErr := os.ReadFile(artifactPath) - if readErr != nil { - t.Fatalf("read artifact: %v", readErr) - } - if string(payload) == string(original) { - t.Fatal("expected replacement artifact to remain after cleanup failure") - } -} - -func TestJoinCZTURL(t *testing.T) { - tests := []struct { - name string - base string - raw string - want string - wantErr bool - }{ - {name: "leading slash", base: "https://czteam.me", raw: "/download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "no leading slash", base: "https://czteam.me", raw: "download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "same host absolute", base: "https://czteam.me", raw: "https://czteam.me/download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "base userinfo stripped", base: "https://user:pass@czteam.me", raw: "download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "same host absolute userinfo stripped", base: "https://czteam.me", raw: "https://user:pass@czteam.me/download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "same host scheme relative", base: "https://czteam.me", raw: "//czteam.me/download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "base path ignored", base: "https://czteam.me/nested/path?x=1", raw: "download.php?id=1", want: "https://czteam.me/download.php?id=1"}, - {name: "absolute offsite", base: "https://czteam.me", raw: "https://cdn.example/download/1", wantErr: true}, - {name: "scheme-relative offsite", base: "https://czteam.me", raw: "//cdn.example/download/1", wantErr: true}, - {name: "same host wrong scheme", base: "https://czteam.me", raw: "http://czteam.me/download.php?id=1", wantErr: true}, - {name: "root path", base: "https://czteam.me", raw: "/", wantErr: true}, - {name: "root query", base: "https://czteam.me", raw: "/?id=1", wantErr: true}, - {name: "query only", base: "https://czteam.me", raw: "?id=1", wantErr: true}, - {name: "pathless", base: "https://czteam.me", raw: "https://czteam.me", wantErr: true}, - {name: "empty", base: "https://czteam.me", raw: " ", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := joinCZTURL(tt.base, tt.raw) - if tt.wantErr { - if err == nil { - t.Fatal("expected error") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Fatalf("expected %q, got %q", tt.want, got) - } - }) - } -} - -func TestResolveCategoryMatrix(t *testing.T) { - tests := []struct { - name string - meta api.PreparedMetadata - want string - wantErr bool - }{ - {name: "display name answer", meta: api.PreparedMetadata{TrackerQuestionnaireAnswers: map[string]map[string]string{trackerName: {"category": "Software"}}}, want: "22"}, - {name: "numeric answer", meta: api.PreparedMetadata{TrackerQuestionnaireAnswers: map[string]map[string]string{trackerName: {"category": "6"}}}, want: "6"}, - {name: "movie hd", meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, Release: api.ReleaseInfo{Resolution: "1080p"}}, want: "29"}, - {name: "tv hd ro", meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}, Release: api.ReleaseInfo{Resolution: "1080p"}, SeasonInt: 1, SubtitleLanguages: []string{"ro"}}, want: "34"}, - {name: "anime", meta: api.PreparedMetadata{Anime: true}, want: "23"}, - {name: "anime hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Anime-Video"}}, want: "23"}, - {name: "video game hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "video-game"}}, want: "29"}, - {name: "game video hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "game-video"}}, want: "29"}, - {name: "game movie hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "game movie"}}, want: "29"}, - {name: "videogame compound", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "videogame"}}, wantErr: true}, - {name: "gameplay compound", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "gameplay"}}, wantErr: true}, - {name: "console hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Games/Consoles"}}, want: "12"}, - {name: "release source dvd", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Source: "DVD"}}, want: "20"}, - {name: "documentary hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Documentary"}}, want: "29"}, - {name: "movie documentary hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Movie Documentary"}}, want: "29"}, - {name: "docs hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Docs"}}, want: "25"}, - {name: "ebook hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "eBook"}}, want: "25"}, - {name: "software", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Software"}}, want: "22"}, - {name: "music", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Music/Audio"}}, want: "6"}, - {name: "music video phrase", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Music Video"}}, want: "30"}, - {name: "music video separator", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "music-video"}}, want: "30"}, - {name: "music video dotted uppercase", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "MUSIC.VIDEO"}}, want: "30"}, - {name: "mvid", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "MVID"}}, want: "30"}, - {name: "generic video hint", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Video", Resolution: "1080p"}}, want: "29"}, - {name: "no hints unknown metadata", meta: api.PreparedMetadata{}, wantErr: true}, - {name: "unknown non-video", meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Other Data"}}, wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := resolveCategory(tt.meta) - if tt.wantErr { - if err == nil { - t.Fatal("expected category error") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Fatalf("expected category %q, got %q", tt.want, got) - } - }) - } -} - -func TestCategoryQuestionnaireRequiresUnknownNonVideo(t *testing.T) { - questionnaire := categoryQuestionnaire(api.PreparedMetadata{Release: api.ReleaseInfo{Category: "Other Data"}}) - if questionnaire == nil || len(questionnaire.Fields) != 1 { - t.Fatalf("expected one category questionnaire field, got %+v", questionnaire) - } - field := questionnaire.Fields[0] - if !field.Required { - t.Fatal("expected unknown non-video category to require an explicit answer") - } - if field.Value != "" { - t.Fatalf("expected no default category value, got %q", field.Value) - } -} - -func TestBuildUploadDryRunBlocksMissingRequiredCategory(t *testing.T) { - req := cztUploadRequest(t, defaultBaseURL) - req.Meta.ExternalIDs.Category = "" - req.Meta.Release.Category = "Other Data" - - entry, err := buildUploadDryRun(context.Background(), req) - if err != nil { - t.Fatalf("unexpected dry-run error: %v", err) - } - if entry.Status != "blocked" { - t.Fatalf("expected blocked dry-run status, got %q", entry.Status) - } - if !strings.Contains(entry.Message, "category questionnaire") { - t.Fatalf("expected category questionnaire message, got %q", entry.Message) - } - if _, ok := entry.Payload["category"]; ok { - t.Fatalf("expected unresolved category omitted from payload, got %q", entry.Payload["category"]) - } - if entry.Questionnaire == nil || len(entry.Questionnaire.Fields) != 1 || !entry.Questionnaire.Fields[0].Required { - t.Fatalf("expected required category questionnaire, got %+v", entry.Questionnaire) - } - - if _, err := prepareUploadState(context.Background(), req, true); err == nil { - t.Fatal("expected actual upload state to still require category") - } -} - -func TestBuildDescriptionAndDryRunUseProvidedAssets(t *testing.T) { - req := cztUploadRequest(t, defaultBaseURL) - - entry, err := buildUploadDryRun(context.Background(), req) - if err != nil { - t.Fatalf("unexpected dry-run error: %v", err) - } - if !strings.Contains(entry.Description, "[img]https://img.example/raw.jpg[/img]") { - t.Fatalf("expected provided screenshot in dry-run description, got %q", entry.Description) - } - if !strings.Contains(entry.Payload["user_descr"], "[img]https://img.example/raw.jpg[/img]") { - t.Fatalf("expected provided screenshot in dry-run payload, got %q", entry.Payload["user_descr"]) - } - if strings.Contains(entry.Payload["user_descr"], "[url=") || strings.Contains(entry.Payload["user_descr"], "[center]") { - t.Fatalf("expected CZT screenshot BBCode to use only img tags, got %q", entry.Payload["user_descr"]) - } - - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "CZT", - Meta: req.Meta, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - Description: "[center]final rewritten body[/center]", - Screenshots: []api.ScreenshotImage{{ - ImgURL: "https://img.example/should-not-append.jpg", - }}, - Final: true, - }, - }) - if err != nil { - t.Fatalf("unexpected description error: %v", err) - } - if result.Description != "[center]final rewritten body[/center]" { - t.Fatalf("expected final description verbatim, got %q", result.Description) - } -} - -func TestBBCODEScreenshotBlockUsesRawURLsAndCapsAtTwo(t *testing.T) { - got := bbcodeScreenshotBlock([]api.ScreenshotImage{ - {ImgURL: "https://img.example/rehosted-only.jpg", WebURL: "https://img.example/page-only"}, - {ImgURL: "https://img.example/rehosted-1.jpg", WebURL: "https://img.example/page-1", RawURL: "https://img.example/raw-1.jpg"}, - {ImgURL: "https://img.example/rehosted-2.jpg", WebURL: "https://img.example/page-2", RawURL: "https://img.example/raw-2.jpg"}, - {ImgURL: "https://img.example/rehosted-3.jpg", WebURL: "https://img.example/page-3", RawURL: "https://img.example/raw-3.jpg"}, - }) - want := "[img]https://img.example/raw-1.jpg[/img]\n[img]https://img.example/raw-2.jpg[/img]" - if got != want { - t.Fatalf("unexpected screenshot block:\nwant %q\ngot %q", want, got) - } - if strings.Contains(got, "[url=") || strings.Contains(got, "[center]") { - t.Fatalf("expected only img BBCode, got %q", got) - } - if strings.Contains(got, "rehosted") || strings.Contains(got, "page-") || strings.Contains(got, "raw-3") { - t.Fatalf("expected raw-only first two images, got %q", got) - } -} - -func validTorrentBytes(t *testing.T) []byte { - t.Helper() - tmp := t.TempDir() - sourcePath := filepath.Join(tmp, "source.bin") - torrentPath := filepath.Join(tmp, "source.torrent") - if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil { - t.Fatalf("write source: %v", err) - } - _, err := mkbrr.Create(mkbrr.CreateOptions{ - Path: sourcePath, - OutputPath: torrentPath, - IsPrivate: true, - }) - if err != nil { - t.Fatalf("create torrent: %v", err) - } - payload, err := os.ReadFile(torrentPath) - if err != nil { - t.Fatalf("read torrent: %v", err) - } - return payload -} - -type cztUploadTestServer struct { - *httptest.Server - handlerErr chan error -} - -func newCZTUploadTestServer(t *testing.T, returnedTorrent []byte, expectedPath string) *cztUploadTestServer { - t.Helper() - handlerErr := make(chan error, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := assertCZTUploadRequest(r, expectedPath); err != nil { - recordCZTUploadHandlerError(handlerErr, err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusCreated) - _, _ = fmt.Fprintf( - w, - `{"id":123,"name":"Release","download_url":"download.php?id=123","torrent_b64":%q}`, - base64.StdEncoding.EncodeToString(returnedTorrent), - ) - })) - return &cztUploadTestServer{Server: server, handlerErr: handlerErr} -} - -func assertCZTUploadRequest(r *http.Request, expectedPath string) error { - if r.URL.Path != expectedPath { - return fmt.Errorf("expected upload path %q, got %q", expectedPath, r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "" { - return errors.New("expected no authorization header") - } - if err := r.ParseMultipartForm(5 << 20); err != nil { - return fmt.Errorf("parse multipart: %w", err) - } - if got := r.FormValue("passkey"); got != "pass" { - return errors.New("expected passkey form field") - } - if got := r.FormValue("user_descr"); !strings.Contains(got, "[img]https://img.example/raw.jpg[/img]") { - return fmt.Errorf("expected raw screenshot img tag in payload, got %q", got) - } else if strings.Contains(got, "[url=") || strings.Contains(got, "[center]") || strings.Contains(got, "rehosted.jpg") || strings.Contains(got, "page") { - return fmt.Errorf("expected only raw screenshot img tags in payload, got %q", got) - } - files := r.MultipartForm.File["file"] - if len(files) != 1 { - return fmt.Errorf("expected one torrent file, got %d", len(files)) - } - file, err := files[0].Open() - if err != nil { - return fmt.Errorf("open multipart file: %w", err) - } - defer func() { - _ = file.Close() - }() - if _, err := io.ReadAll(file); err != nil { - return fmt.Errorf("read multipart file: %w", err) - } - return nil -} - -func recordCZTUploadHandlerError(handlerErr chan<- error, err error) { - select { - case handlerErr <- err: - default: - } -} - -func (s *cztUploadTestServer) assertNoHandlerError(t *testing.T) { - t.Helper() - if err := s.handlerError(); err != nil { - t.Fatalf("upload handler assertion: %v", err) - } -} - -func (s *cztUploadTestServer) assertHandlerErrorContains(t *testing.T, want string) { - t.Helper() - err := s.handlerError() - if err == nil { - t.Fatal("expected upload handler assertion error") - } - if !strings.Contains(err.Error(), want) { - t.Fatalf("expected upload handler assertion containing %q, got %v", want, err) - } -} - -func (s *cztUploadTestServer) handlerError() error { - select { - case err := <-s.handlerErr: - return err - default: - return nil - } -} - -func cztUploadRequest(t *testing.T, trackerURL string) trackers.UploadRequest { - t.Helper() - if strings.TrimSpace(trackerURL) != "" { - withCZTBaseURL(t, trackerURL) - } - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "Release.torrent") - if err := os.WriteFile(torrentPath, []byte("source-torrent"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - sourcePath := filepath.Join(tmp, "Release.mkv") - if err := os.WriteFile(sourcePath, []byte("video"), 0o600); err != nil { - t.Fatalf("write source: %v", err) - } - return trackers.UploadRequest{ - Tracker: "CZT", - Meta: api.PreparedMetadata{ - SourcePath: sourcePath, - TorrentPath: torrentPath, - ReleaseName: "Release.2026.1080p.WEB-DL", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - }, - TrackerConfig: config.TrackerConfig{ - Passkey: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "state", "upbrr.db")}}, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - Description: "kept description", - Screenshots: []api.ScreenshotImage{{ - ImgURL: "https://img.example/rehosted.jpg", - WebURL: "https://img.example/page", - RawURL: "https://img.example/raw.jpg", - }, { - ImgURL: "https://img.example/rehosted-2.jpg", - WebURL: "https://img.example/page-2", - RawURL: "https://img.example/raw-2.jpg", - }, { - ImgURL: "https://img.example/rehosted-3.jpg", - WebURL: "https://img.example/page-3", - RawURL: "https://img.example/raw-3.jpg", - }}, - }, - } -} - -func withCZTBaseURL(t *testing.T, baseURL string) { - t.Helper() - old := cztBaseURL - cztBaseURL = baseURL - t.Cleanup(func() { - cztBaseURL = old - }) -} diff --git a/internal/trackers/impl/czt_dupe_contract_test.go b/internal/trackers/impl/czt_dupe_contract_test.go new file mode 100644 index 00000000..04c1f0bc --- /dev/null +++ b/internal/trackers/impl/czt_dupe_contract_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl_test + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestCZTServiceIgnoresAPIKeyOnlyConfig(t *testing.T) { + t.Parallel() + cfg := config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"CZT": {APIKey: "bearer-token"}}}} + registry, err := trackerimpl.NewRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + svc := dupe.NewServiceWithRegistry(cfg, api.NopLogger{}, registry) + summary, err := svc.Check(context.Background(), api.DuplicateSubject{SourcePath: "source.mkv", Release: api.ReleaseInfo{Title: "Movie"}}, []string{"CZT"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(summary.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(summary.Results)) + } + result := summary.Results[0] + if result.Status != "skipped" || !result.Skipped || result.Error != "" || result.SkipCode != dupe.NotRunMissingCredentials { + t.Fatalf("unexpected skipped result: %#v", result) + } + if !strings.Contains(result.SkipReason, "missing passkey") { + t.Fatalf("expected passkey skip, got %q", result.SkipReason) + } + if strings.Contains(result.SkipReason, "bearer-token") || strings.Contains(strings.Join(result.Notes, " "), "bearer-token") { + t.Fatalf("skip result leaked API key: %#v", result) + } +} diff --git a/internal/trackers/impl/data_contract_test.go b/internal/trackers/impl/data_contract_test.go new file mode 100644 index 00000000..da8f7dff --- /dev/null +++ b/internal/trackers/impl/data_contract_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + trackerdata "github.com/autobrr/upbrr/internal/trackers/data" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" + ptpimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ptp" + "github.com/autobrr/upbrr/pkg/api" +) + +func unit3DTestConfig() config.Config { + return config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "BLU": {AnnounceURL: "https://blutopia.cc/announce"}, + }}} +} + +func newUnit3DDataClient(t *testing.T, httpClient *http.Client) *trackerdata.Client { + t.Helper() + registry, err := trackerimpl.NewRegistry() + if err != nil { + t.Fatalf("create tracker registry: %v", err) + } + return trackerdata.NewClientWithRegistry(unit3DTestConfig(), api.NopLogger{}, httpClient, registry) +} + +type rewriteHostTransport struct { + base *url.URL + rt http.RoundTripper +} + +func (t rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = t.base.Scheme + clone.URL.Host = t.base.Host + clone.Host = t.base.Host + resp, err := t.rt.RoundTrip(clone) + if err != nil { + return resp, fmt.Errorf("rewrite host round trip: %w", err) + } + return resp, nil +} + +func TestLookupPTP(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/torrents.php": + switch { + case r.URL.Query().Get("torrentid") != "": + _ = json.NewEncoder(w).Encode(map[string]any{ + "ImdbId": "1122334", + "Torrents": []any{ + map[string]any{"Id": "777", "InfoHash": "abc123"}, + }, + }) + case r.URL.Query().Get("action") == "get_description": + _, _ = w.Write([]byte("Desc\nhttps://pixhost.to/abc.png")) + default: + http.NotFound(w, r) + } + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg := config.Config{ + Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "PTP": {PTPAPIUser: "user", PTPAPIKey: "key"}, + }}, + } + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + httpClient := server.Client() + httpClient.Transport = rewriteHostTransport{base: baseURL, rt: httpClient.Transport} + registry := trackers.NewRegistry() + if err := registry.Register(ptpimpl.New()); err != nil { + t.Fatalf("register PTP: %v", err) + } + client := trackerdata.NewClientWithRegistry(cfg, api.NopLogger{}, httpClient, registry) + + ptpResult, err := client.Lookup(context.Background(), "PTP", "777", api.UploadSubject{}, "release.mkv", false, true) + if err != nil { + t.Fatalf("ptp lookup failed: %v", err) + } + if ptpResult.IMDBID != 1122334 || ptpResult.TrackerID != "777" || ptpResult.InfoHash != "abc123" { + t.Fatalf("unexpected ptp result: %+v", ptpResult) + } + if ptpResult.Description != "Desc" || len(ptpResult.Images) != 1 { + t.Fatalf("unexpected ptp description/images: %+v", ptpResult) + } +} + +func TestLookupUnit3DOnlyIDKeepsImages(t *testing.T) { + t.Parallel() + + pngBytes := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0x99, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, + 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/torrents/"): + imageURL := "http://93.184.216.34/images/shot.png" + description := "[url=https://example.com/view][img]" + imageURL + "[/img][/url]" + _ = json.NewEncoder(w).Encode(map[string]any{ + "attributes": map[string]any{ + "tmdb_id": 100, + "description": description, + }, + }) + case r.URL.Path == "/images/shot.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngBytes) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := server.Client() + transport := httpClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + httpClient.Transport = rewriteHostTransport{base: baseURL, rt: transport} + + client := newUnit3DDataClient(t, httpClient) + + result, err := client.Lookup(context.Background(), "BLU", "777", api.UploadSubject{}, "release.mkv", true, true) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if result.TMDBID != 100 { + t.Fatalf("expected tmdb id, got %+v", result) + } + if result.Description != "" { + t.Fatalf("expected onlyID to clear description, got %q", result.Description) + } + if len(result.Images) != 1 { + t.Fatalf("expected keepImages to retain images with onlyID=true, got %d", len(result.Images)) + } +} + +func TestLookupUnit3DRejectsLinkedPrivateRawURLBeforeFetch(t *testing.T) { + t.Parallel() + + var privateRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/torrents/"): + description := "[url=http://127.0.0.1/private.png][img]http://93.184.216.34/thumb.png[/img][/url]" + _ = json.NewEncoder(w).Encode(map[string]any{ + "attributes": map[string]any{ + "tmdb_id": 100, + "description": description, + }, + }) + case r.URL.Path == "/private.png": + privateRequests.Add(1) + http.Error(w, "private image should not be fetched", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := server.Client() + transport := httpClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + httpClient.Transport = rewriteHostTransport{base: baseURL, rt: transport} + + client := newUnit3DDataClient(t, httpClient) + result, err := client.Lookup(context.Background(), "BLU", "777", api.UploadSubject{}, "release.mkv", false, true) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if len(result.Images) != 0 || len(result.Validated) != 0 { + t.Fatalf("expected private linked image to be rejected, got images=%+v validated=%+v", result.Images, result.Validated) + } + if got := privateRequests.Load(); got != 0 { + t.Fatalf("expected private raw URL not to be fetched, got %d request(s)", got) + } +} + +func TestLookupUnit3DAllowsPublicLinkedRawURL(t *testing.T) { + t.Parallel() + + pngBytes := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0x99, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, + 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, + } + + var fullRequests atomic.Int32 + var thumbRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/torrents/"): + description := "[url=http://93.184.216.34/images/full.png][img]http://93.184.216.34/images/thumb.png[/img][/url]" + _ = json.NewEncoder(w).Encode(map[string]any{ + "attributes": map[string]any{ + "tmdb_id": 100, + "description": description, + }, + }) + case r.URL.Path == "/images/full.png": + fullRequests.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngBytes) + case r.URL.Path == "/images/thumb.png": + thumbRequests.Add(1) + http.Error(w, "thumbnail should not be validated when linked full-size URL is public", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := server.Client() + transport := httpClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + httpClient.Transport = rewriteHostTransport{base: baseURL, rt: transport} + + client := newUnit3DDataClient(t, httpClient) + result, err := client.Lookup(context.Background(), "BLU", "777", api.UploadSubject{}, "release.mkv", false, true) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if len(result.Images) != 1 || len(result.Validated) != 1 { + t.Fatalf("expected public linked image to validate, got images=%+v validated=%+v", result.Images, result.Validated) + } + if got := result.Images[0].RawURL; got != "http://93.184.216.34/images/full.png" { + t.Fatalf("expected linked full-size raw URL, got %q", got) + } + if got := result.Images[0].ImgURL; got != "http://93.184.216.34/images/thumb.png" { + t.Fatalf("expected thumbnail image URL preserved, got %q", got) + } + if got := fullRequests.Load(); got != 1 { + t.Fatalf("expected one public full-size fetch, got %d", got) + } + if got := thumbRequests.Load(); got != 0 { + t.Fatalf("expected thumbnail not to be fetched, got %d request(s)", got) + } +} + +func TestLookupUnit3DDescriptionFlagsGateDescriptionAndImages(t *testing.T) { + t.Parallel() + + pngBytes := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0x99, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, + 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/torrents/"): + imageURL := "http://93.184.216.34/images/shot.png" + description := "[center]Fetched tracker body[/center]\n\n[center][url=https://example.com/view][img]" + imageURL + "[/img][/url][/center]" + _ = json.NewEncoder(w).Encode(map[string]any{ + "attributes": map[string]any{ + "tmdb_id": 100, + "description": description, + }, + }) + case r.URL.Path == "/images/shot.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngBytes) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := server.Client() + transport := httpClient.Transport + if transport == nil { + transport = http.DefaultTransport + } + httpClient.Transport = rewriteHostTransport{base: baseURL, rt: transport} + + client := newUnit3DDataClient(t, httpClient) + ctx := context.Background() + + result, err := client.Lookup(ctx, "BLU", "777", api.UploadSubject{}, "release.mkv", false, false) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if result.Description != "[center]Fetched tracker body[/center]" { + t.Fatalf("expected cleaned description when onlyID=false, got %q", result.Description) + } + if len(result.Images) != 0 { + t.Fatalf("expected keepImages=false to skip images, got %d", len(result.Images)) + } + + result, err = client.Lookup(ctx, "BLU", "777", api.UploadSubject{}, "release.mkv", true, false) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if result.Description != "" { + t.Fatalf("expected onlyID=true to clear description, got %q", result.Description) + } + if len(result.Images) != 0 { + t.Fatalf("expected keepImages=false to skip images with onlyID=true, got %d", len(result.Images)) + } + + result, err = client.Lookup(ctx, "BLU", "777", api.UploadSubject{}, "release.mkv", false, true) + if err != nil { + t.Fatalf("unit3d lookup failed: %v", err) + } + if result.Description != "[center]Fetched tracker body[/center]" { + t.Fatalf("expected cleaned description with keepImages=true, got %q", result.Description) + } + if len(result.Images) != 1 { + t.Fatalf("expected keepImages=true to retain images, got %d", len(result.Images)) + } +} diff --git a/internal/trackers/impl/dc/definition.go b/internal/trackers/impl/dc/definition.go deleted file mode 100644 index f007078f..00000000 --- a/internal/trackers/impl/dc/definition.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package dc - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "DC" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "dc", Description: description}, nil -} diff --git a/internal/trackers/impl/dc/upload.go b/internal/trackers/impl/dc/upload.go deleted file mode 100644 index 92a279ed..00000000 --- a/internal/trackers/impl/dc/upload.go +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package dc - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "maps" - "net/http" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://digitalcore.club" - apiBaseURL = baseURL + "/api/v1/torrents" - sourceFlag = "DigitalCore.club" -) - -type uploadState struct { - torrentPath string - releaseName string - description string - mediaInfo string - fields map[string]string - blockedReason string -} - -type uploadResponse struct { - ID any `json:"id"` - Message string `json:"message"` -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: DC %s", state.blockedReason) - } - - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ - FieldName: "file", - FileName: state.releaseName + ".torrent", - Path: state.torrentPath, - }}) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/upload", strings.NewReader(string(body))) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: DC request build: %w", err) - } - httpReq.Body = io.NopCloser(strings.NewReader(string(body))) - httpReq.ContentLength = int64(len(body)) - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - httpReq.Header.Set("X-Api-Key", strings.TrimSpace(req.TrackerConfig.APIKey)) - - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: DC upload request: %w", err) - } - defer resp.Body.Close() - - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 300, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: DC read upload response: %w", err) - } - var decoded uploadResponse - if len(responseBody) > 0 { - if err := json.Unmarshal(responseBody, &decoded); err != nil { - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return api.UploadSummary{}, commonhttp.UploadHTTPError("DC", resp.StatusCode, responsePreview) - } - return api.UploadSummary{}, fmt.Errorf("trackers: DC decode response: %w", err) - } - } - torrentID := strings.TrimSpace(fmt.Sprint(decoded.ID)) - if resp.StatusCode >= 200 && resp.StatusCode < 300 && torrentID != "" && torrentID != "" { - torrentURL := baseURL + "/torrent/" + torrentID + "/" - downloadURL := apiBaseURL + "/download/" + torrentID - artifactPath := "" - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "DC") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "DC", - TorrentID: torrentID, - TorrentURL: torrentURL, - DownloadURL: downloadURL, - TorrentPath: artifactPath, - }}, - }, nil - } - - if _, artifactErr := commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "DC", "upload_failure", responsePreview, ".json"); artifactErr != nil && req.Logger != nil { - req.Logger.Warnf("trackers: DC failure artifact write failed: %v", artifactErr) - } - message := metautil.FirstNonEmptyTrimmed(commonhttp.ExtractHTTPErrorDetail(responsePreview), commonhttp.RedactErrorDetail(decoded.Message), commonhttp.RedactErrorDetail(string(responsePreview)), "upload failed") - return api.UploadSummary{}, fmt.Errorf("trackers: DC %s", message) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "DC", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "dc", - Description: state.description, - Endpoint: apiBaseURL + "/upload", - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{ - Field: "file", - Path: state.torrentPath, - Present: strings.TrimSpace(state.torrentPath) != "", - }}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { - return uploadState{}, errors.New("trackers: DC missing api_key") - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - mediaInfo, err := resolveMediaInfo(req.Meta) - if err != nil { - return uploadState{}, err - } - releaseName := resolveUploadName(req.Meta) - fields := map[string]string{ - "category": strconv.Itoa(resolveCategoryID(req.Meta)), - "imdbId": resolveIMDbID(req.Meta), - "nfo": description, - "mediainfo": mediaInfo, - "reqid": "0", - "section": "new", - "frileech": "1", - "anonymousUpload": resolveAnon(req), - "p2p": "0", - "unrar": "1", - } - state := uploadState{ - torrentPath: torrentPath, - releaseName: releaseName, - description: description, - mediaInfo: mediaInfo, - fields: fields, - } - if strings.TrimSpace(fields["imdbId"]) == "" { - state.blockedReason = "missing IMDb ID" - } - return state, nil -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - meta := req.Meta - var parts []string - - // Custom Header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // TV Episode details - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") - } - - // File information - if media := resolveMedia(req, meta); media != "" { - parts = append(parts, strings.TrimSpace(media)) - } - - // User description - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - - // Combined Screenshots - allShots := make([]api.ScreenshotImage, 0, len(assets.MenuImages)+len(assets.Screenshots)) - allShots = append(allShots, assets.MenuImages...) - allShots = append(allShots, assets.Screenshots...) - if shots := screenshotBlock(allShots); shots != "" { - parts = append(parts, shots) - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // Signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("[center][url=%s]%s[/url][/center]", link, text)) - - // Join and finalize - description := strings.Join(parts, "\n\n") - finalized := bbcode.FinalizeTrackerDescription("DC", description) - - // Debug saving - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "DC", req.AppConfig.MainSettings.DBPath, finalized, req.Logger) - } - - return finalized -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - resolution := strings.ToLower(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Resolution, meta.ReleaseNameNoTag, meta.ReleaseName))) - category := categoryOf(meta) - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - if category == "TV" { - return 14 - } - if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "2160p") { - return 38 - } - return 3 - } - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - if category == "TV" { - return 11 - } - return 1 - } - if category == "TV" && meta.TVPack { - return 12 - } - if isSD(meta) { - if category == "TV" { - return 10 - } - return 2 - } - switch category { - case "TV": - switch strings.TrimSpace(meta.Release.Resolution) { - case "2160p": - return 13 - case "1080p", "1080i": - return 9 - default: - return 8 - } - default: - switch strings.TrimSpace(meta.Release.Resolution) { - case "2160p": - return 4 - case "1080p", "1080i": - return 6 - default: - _ = resolution - return 5 - } - } -} - -func resolveUploadName(meta api.PreparedMetadata) string { - name := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.SceneName), strings.TrimSpace(meta.ReleaseNameClean), strings.TrimSpace(meta.ReleaseName), strings.TrimSpace(meta.Filename)) - if name == "" { - name = "release" - } - if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { - return strings.TrimSpace(meta.SceneName) + " [UNRAR]" - } - name = strings.NewReplacer("DD+", "DDP", "DTS:", "DTS-", "HDR10+", "HDR10P").Replace(name) - out := strings.Builder{} - for _, r := range name { - if r > 127 { - continue - } - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' || r == '.' || r == '-' { - out.WriteRune(r) - } - } - return strings.TrimSpace(out.String()) -} - -func resolveMediaInfo(meta api.PreparedMetadata) (string, error) { - if text := metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)); text != "" { - return text, nil - } - return "", errors.New("trackers: DC missing mediainfo") -} - -func resolveMedia(req trackers.UploadRequest, meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - bdinfo, _ := trackers.ReadBDInfo(req.AppConfig.MainSettings.DBPath, meta) - return strings.TrimSpace(bdinfo) - } - return metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)) -} - -func resolveIMDbID(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID > 0 { - return "tt" + strconv.Itoa(meta.ExternalIDs.IMDBID) - } - return "" -} - -func resolveAnon(req trackers.UploadRequest) string { - if req.TrackerConfig.Anon { - return "1" - } - return "0" -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.ToUpper(strings.TrimSpace(meta.ExternalIDs.Category)); category != "" { - return category - } - return strings.ToUpper(strings.TrimSpace(meta.MediaInfoCategory)) -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - parts := make([]string, 0, len(images)) - for _, image := range images { - if strings.TrimSpace(image.WebURL) == "" || strings.TrimSpace(image.RawURL) == "" { - continue - } - parts = append(parts, "[url="+strings.TrimSpace(image.WebURL)+"][img=350]"+strings.TrimSpace(image.RawURL)+"[/img][/url]") - } - if len(parts) == 0 { - return "" - } - return "[center]" + strings.Join(parts, " ") + "[/center]" -} - -func isSD(meta api.PreparedMetadata) bool { - resolution := strings.TrimSpace(meta.Release.Resolution) - return resolution == "480p" || resolution == "576p" || resolution == "" -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/dupe_contract_test.go b/internal/trackers/impl/dupe_contract_test.go new file mode 100644 index 00000000..30851ae6 --- /dev/null +++ b/internal/trackers/impl/dupe_contract_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestEveryTrackerExposesOneStructuralDuplicateFactory(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatal(err) + } + for _, name := range registry.Names() { + t.Run(name, func(t *testing.T) { + definition, ok := registry.Lookup(name) + if !ok { + t.Fatal("definition missing") + } + factory, ok := definition.(dupe.Factory) + if !ok { + t.Fatalf("%s does not expose dupe.Factory", name) + } + adapter := dupe.NewAdapter(factory, name, config.Config{}, http.DefaultClient, api.NopLogger{}, registry) + if adapter == nil { + t.Fatal("factory returned nil adapter") + } + result := adapter.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "C:/media/Example.Release.2026.1080p-GRP.mkv", + ReleaseName: "Example.Release.2026.1080p-GRP", + }) + switch result.Disposition() { + case dupe.DispositionInvalid: + t.Fatalf("invalid disposition %v", result.Disposition()) + case dupe.DispositionResolved: + case dupe.DispositionNotRun: + if strings.TrimSpace(result.Code()) == "" || strings.TrimSpace(result.SafeMessage()) == "" { + t.Fatalf("invalid not-run result code=%q message=%q", result.Code(), result.SafeMessage()) + } + case dupe.DispositionFailed: + if strings.TrimSpace(result.Code()) == "" || strings.TrimSpace(result.SafeMessage()) == "" { + t.Fatalf("invalid failed result code=%q message=%q", result.Code(), result.SafeMessage()) + } + } + }) + } +} diff --git a/internal/trackers/impl/dupe_handlers_contract_test.go b/internal/trackers/impl/dupe_handlers_contract_test.go new file mode 100644 index 00000000..00316d69 --- /dev/null +++ b/internal/trackers/impl/dupe_handlers_contract_test.go @@ -0,0 +1,360 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + bjsimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bjs" + btimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bt" + ffimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ff" + flimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/fl" + hdsimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/hds" + hdtimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/hdt" + isimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/is" + ptsimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/pts" + thrimpl "github.com/autobrr/upbrr/internal/trackers/impl/standalone/thr" + "github.com/autobrr/upbrr/pkg/api" +) + +type handlerRewriteTransport struct { + base *url.URL + rt http.RoundTripper +} + +func (t handlerRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = t.base.Scheme + clone.URL.Host = t.base.Host + clone.Host = t.base.Host + response, err := t.rt.RoundTrip(clone) + if err != nil { + return response, fmt.Errorf("rewrite tracker request: %w", err) + } + return response, nil +} + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +func TestSiteHandlersSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tracker string + meta api.DuplicateSubject + setup func(t *testing.T, baseURL string, dbPath string) + handler func(cfg config.Config, client *http.Client) dupe.Adapter + validate func(t *testing.T, entries []api.DupeEntry) + }{ + { + name: "BT", + tracker: "BT", + meta: api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123}, + Release: api.ReleaseInfo{Title: "Movie"}, + SourcePath: "x", + }, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "BT", hostFromBaseURL(t, "https://brasiltracker.org")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(btimpl.New(), "BT", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "Movie.2024.1080p.WEB-DL-GRP" { + t.Fatalf("unexpected BT entries: %#v", entries) + } + }, + }, + { + name: "FL", + tracker: "FL", + meta: api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + SourcePath: "x", + }, + setup: func(t *testing.T, _ string, dbPath string) { + writeJSONCookie(t, dbPath, "FL", `{"sid":"cookie"}`) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(flimpl.New(), "FL", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].ID != "1234" { + t.Fatalf("unexpected FL entries: %#v", entries) + } + }, + }, + { + name: "FF", + tracker: "FF", + meta: api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 123}, SourcePath: "x"}, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "FF", hostFromBaseURL(t, "https://www.funfile.org")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(ffimpl.New(), "FF", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "Fun.Movie.2024.1080p.BluRay.x264-GRP" { + t.Fatalf("unexpected FF entries: %#v", entries) + } + }, + }, + { + name: "BJS", + tracker: "BJS", + meta: api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 123}, SourcePath: "x"}, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "BJS", hostFromBaseURL(t, "https://bj-share.info")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(bjsimpl.New(), "BJS", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].ID != "44" { + t.Fatalf("unexpected BJS entries: %#v", entries) + } + }, + }, + { + name: "HDS", + tracker: "HDS", + meta: api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + SourcePath: "x", + }, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "HDS", hostFromBaseURL(t, "https://hd-space.org")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(hdsimpl.New(), "HDS", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || !entries[0].SizeKnown { + t.Fatalf("unexpected HDS entries: %#v", entries) + } + }, + }, + { + name: "HDT", + tracker: "HDT", + meta: api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + SourcePath: "x", + }, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "HDT", hostFromBaseURL(t, "https://hd-torrents.me")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(hdtimpl.New(), "HDT", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "Movie.2024.1080p.REMUX-GRP" { + t.Fatalf("unexpected HDT entries: %#v", entries) + } + }, + }, + { + name: "IS", + tracker: "IS", + meta: api.DuplicateSubject{Identity: api.ExternalIdentity{Category: "MOVIE", IMDBID: 123}, SourcePath: "x"}, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "IS", hostFromBaseURL(t, "https://immortalseed.me")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(isimpl.New(), "IS", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "Movie.2024.1080p.WEB-DL-GRP" { + t.Fatalf("unexpected IS entries: %#v", entries) + } + }, + }, + { + name: "PTS", + tracker: "PTS", + meta: api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 123}, SourcePath: "x"}, + setup: func(t *testing.T, _ string, dbPath string) { + writeTextCookie(t, dbPath, "PTS", hostFromBaseURL(t, "https://www.ptskit.org")) + }, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(ptsimpl.New(), "PTS", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "PTS.Movie.2024.1080p.WEB-DL-GRP" { + t.Fatalf("unexpected PTS entries: %#v", entries) + } + }, + }, + { + name: "THR", + tracker: "THR", + meta: api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 123}, SourcePath: "x"}, + setup: func(_ *testing.T, _ string, _ string) {}, + handler: func(cfg config.Config, client *http.Client) dupe.Adapter { + return dupe.NewAdapter(thrimpl.New(), "THR", cfg, client, api.NopLogger{}) + }, + validate: func(t *testing.T, entries []api.DupeEntry) { + if len(entries) != 1 || entries[0].Name != "THR.Movie.2024.1080p.BluRay-GRP" { + t.Fatalf("unexpected THR entries: %#v", entries) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "ua.db") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch tc.tracker { + case "BT": + if r.URL.Path == "/torrents.php" { + if r.URL.RawQuery == "id=99" { + _, _ = w.Write([]byte(`
Movie.2024.1080p.WEB-DL-GRP
m2ts
`)) + return + } + if strings.Contains(r.URL.RawQuery, "searchstr=") { + _, _ = w.Write([]byte(`
group
`)) + return + } + } + case "FL": + if r.URL.Path == "/browse.php" { + _, _ = w.Write([]byte(`x`)) + return + } + case "FF": + if r.URL.Path == "/torrents.php" && r.URL.RawQuery == "id=55" { + _, _ = w.Write([]byte(`
Fun.Movie.2024.1080p.BluRay.x264-GRP
`)) + return + } + if r.URL.Path == "/torrents.php" { + _, _ = w.Write([]byte(`
group
`)) + return + } + case "BJS": + if r.URL.Path == "/torrents.php" { + _, _ = w.Write([]byte(``)) + return + } + case "HDS": + if r.URL.Path == "/index.php" { + _, _ = w.Write([]byte(`Show/Hide Categories
HDS.Movie.2024.1080p.BluRay-GRP10.5 GB
`)) + return + } + case "HDT": + if r.URL.Path == "/torrents.php" { + _, _ = w.Write([]byte(`
Movie.2024.1080p.REMUX-GRP15.2 GiB
`)) + return + } + case "IS": + if r.URL.Path == "/browse.php" { + _, _ = w.Write([]byte(`
Movie.2024.1080p.WEB-DL-GRP8.2 GB
`)) + return + } + case "PTS": + if r.URL.Path == "/torrents.php" { + _, _ = w.Write([]byte(`
PTS.Movie.2024.1080p.WEB-DL-GRP
`)) + return + } + case "THR": + if r.URL.Path == "/login.php" { + _, _ = w.Write([]byte(`
`)) + return + } + if r.URL.Path == "/takelogin.php" { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "cookie", + Path: "/", + }) + _, _ = w.Write([]byte("ok")) + return + } + if r.URL.Path == "/browse.php" { + _, _ = w.Write([]byte(`link`)) + return + } + } + http.NotFound(w, r) + })) + defer server.Close() + + tc.setup(t, server.URL, dbPath) + cfg := config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: dbPath}, + Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + tc.tracker: { + Username: "user", + Password: "pass", + }, + }}, + } + base, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server URL: %v", err) + } + client := &http.Client{Transport: handlerRewriteTransport{base: base, rt: server.Client().Transport}} + entries, notes, err := adapterEvidence(tc.handler(cfg, client).Search(context.Background(), tc.meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("unexpected notes: %v", notes) + } + tc.validate(t, entries) + }) + } +} + +func writeTextCookie(t *testing.T, dbPath string, tracker string, domain string) { + t.Helper() + cookieDir := filepath.Join(filepath.Dir(dbPath), "cookies") + if err := os.MkdirAll(cookieDir, 0o755); err != nil { + t.Fatalf("mkdir cookie dir: %v", err) + } + line := domain + "\tTRUE\t/\tFALSE\t0\tsession\tcookie\n" + if err := os.WriteFile(filepath.Join(cookieDir, tracker+".txt"), []byte(line), 0o600); err != nil { + t.Fatalf("write cookie file: %v", err) + } +} + +func writeJSONCookie(t *testing.T, dbPath string, tracker string, payload string) { + t.Helper() + cookieDir := filepath.Join(filepath.Dir(dbPath), "cookies") + if err := os.MkdirAll(cookieDir, 0o755); err != nil { + t.Fatalf("mkdir cookie dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cookieDir, tracker+".json"), []byte(payload), 0o600); err != nil { + t.Fatalf("write cookie file: %v", err) + } +} + +func hostFromBaseURL(t *testing.T, baseURL string) string { + t.Helper() + parsed, err := url.Parse(baseURL) + if err != nil { + t.Fatalf("parse base URL: %v", err) + } + return parsed.Hostname() +} diff --git a/internal/trackers/impl/ff/definition.go b/internal/trackers/impl/ff/definition.go deleted file mode 100644 index f0bc4ecd..00000000 --- a/internal/trackers/impl/ff/definition.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ff - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "FF" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(assets) - return trackers.DescriptionResult{Group: "ff", Description: description}, nil -} diff --git a/internal/trackers/impl/ff/upload.go b/internal/trackers/impl/ff/upload.go deleted file mode 100644 index 93492cd3..00000000 --- a/internal/trackers/impl/ff/upload.go +++ /dev/null @@ -1,396 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ff - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "maps" - "net/http" - "net/url" - "path/filepath" - "regexp" - "strings" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://www.funfile.org" - loginURL = baseURL + "/takelogin.php" - uploadURL = baseURL + "/takeupload.php" - torrentURL = baseURL + "/details.php?id=" - sourceFlag = "FunFile" -) - -var idPattern = regexp.MustCompile(`details\.php\?id=(\d+)`) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - extraFiles []commonhttp.FileField - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req, false) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: FF %s", state.blockedReason) - } - files := append([]commonhttp.FileField{{ - FieldName: "file", - FileName: state.releaseName + ".torrent", - Path: state.torrentPath, - }}, state.extraFiles...) - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: FF request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - client := httpclient.CloneWithTimeout(&http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, httpclient.DefaultTimeout) - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: FF upload request: %w", err) - } - defer resp.Body.Close() - location := resp.Header.Get("Location") - match := idPattern.FindStringSubmatch(location) - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) - if resp.StatusCode == http.StatusFound && len(match) >= 2 { - id := match[1] - tURL := torrentURL + id - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "FF") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{Tracker: "FF", TorrentID: id, TorrentURL: tURL, DownloadURL: tURL, TorrentPath: artifactPath}}}, nil - } - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "FF", "upload_failure", bodyBytes, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("FF", resp.StatusCode, bodyBytes) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req, true) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "FF", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "ff", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest, dryRun bool) (uploadState, []*http.Cookie, error) { - cookies, err := resolveCookies(ctx, req.Logger, req.TrackerConfig, req.AppConfig.MainSettings.DBPath, dryRun) - if err != nil { - return uploadState{}, nil, err - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(assets) - fields := map[string]string{ - "MAX_FILE_SIZE": "10000000", - "type": resolveTypeID(req.Meta), - "tags": "", - "descr": description, - } - category := strings.ToUpper(strings.TrimSpace(categoryOf(req.Meta))) - switch { - case req.Meta.Anime: - fields["anime_type"] = resolveAnimeType(req.Meta) - fields["anime_source"] = resolveAnimeSource(req.Meta) - fields["anime_container"] = "mkv" - fields["anime_v_res"] = req.Meta.Release.Resolution - fields["anime_v_dar"] = "16_9" - fields["anime_v_codec"] = resolveAnimeVCodec(req.Meta) - case category == "MOVIE": - fields["movie_type"] = resolveMovieType(req.Meta) - fields["movie_source"] = resolveMovieSource(req.Meta) - fields["movie_imdb"] = resolveIMDbURL(req.Meta) - fields["pack"] = "0" - default: - fields["tv_type"] = resolveTVType(req.Meta) - fields["tv_source"] = resolveTVSource(req.Meta) - fields["tv_imdb"] = resolveIMDbURL(req.Meta) - fields["pack"] = "0" - if req.Meta.TVPack { - fields["pack"] = "1" - } - } - state := uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: resolveName(req.Meta), - fields: fields, - extraFiles: resolveExtraFiles(ctx, req.Meta), - } - return state, cookies, nil -} - -func resolveCookies(ctx context.Context, logger api.Logger, cfg config.TrackerConfig, dbPath string, dryRun bool) ([]*http.Cookie, error) { - loaded, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "FF", "www.funfile.org") - if err != nil { - if logger != nil { - logger.Debugf("trackers: LoadTrackerHTTPCookies failed for FF/www.funfile.org, dbPath=%s: %v", dbPath, err) - } - } else if len(loaded) > 0 { - return loaded, nil - } - if dryRun { - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: FF cookies not found") - } - // #nosec G124 -- Dry-run sentinel is an outbound tracker jar cookie, not a browser-set cookie. - return []*http.Cookie{{Name: "dryrun", Value: "1", Domain: ".funfile.org", Path: "/"}}, nil - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: FF cookie invalid/missing and username/password not configured") - } - data := url.Values{} - data.Set("returnto", "/index.php") - data.Set("username", strings.TrimSpace(cfg.Username)) - data.Set("password", strings.TrimSpace(cfg.Password)) - data.Set("login", "Login") - req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("trackers: FF login request build: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "upbrr") - client := httpclient.CloneWithTimeout(&http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, httpclient.DefaultTimeout) - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("trackers: FF login request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusFound { - return nil, fmt.Errorf("trackers: FF login failed status=%d", resp.StatusCode) - } - return resp.Cookies(), nil -} - -func buildDescription(assets trackers.DescriptionAssets) string { - if strings.TrimSpace(assets.Description) != "" { - return bbcode.FinalizeTrackerDescription("FF", strings.TrimSpace(assets.Description)) - } - return "" -} - -func resolveExtraFiles(ctx context.Context, meta api.PreparedMetadata) []commonhttp.FileField { - files := make([]commonhttp.FileField, 0, 2) - if ctx == nil { - return files - } - if poster := resolvePoster(meta); poster != "" { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, poster, nil) - if err == nil { - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) - if err == nil { - defer resp.Body.Close() - if body, err := io.ReadAll(resp.Body); err == nil && len(body) > 0 { - files = append(files, commonhttp.FileField{FieldName: "poster", FileName: "poster.jpg", Content: body}) - } - } - } - } - dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) - if payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo"); err == nil { - files = append(files, commonhttp.FileField{FieldName: "nfo", FileName: filepath.Base(path), Content: payload}) - } - return files -} - -func resolveTypeID(meta api.PreparedMetadata) string { - if meta.Anime { - return "44" - } - if strings.EqualFold(categoryOf(meta), "TV") { - return "7" - } - return "19" -} - -func resolveMovieType(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.Source), "DVD") { - return "DVDR" - } - if strings.Contains(strings.ToLower(meta.VideoCodec), "hevc") || strings.Contains(strings.ToLower(meta.VideoEncode), "265") { - return "x265" - } - return "x264" -} - -func resolveTVType(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.Source), "DVD") { - return "DVDR" - } - if strings.Contains(strings.ToLower(meta.Source), "web") { - if isSD(meta) { - return "Web-SD" - } - return "Web-HD" - } - if strings.Contains(strings.ToLower(meta.VideoCodec), "hevc") || strings.Contains(strings.ToLower(meta.VideoEncode), "265") { - if isSD(meta) { - return "x265-SD" - } - return "x265-HD" - } - if isSD(meta) { - return "x264-SD" - } - return "x264-HD" -} - -func resolveAnimeType(meta api.PreparedMetadata) string { - if meta.SeasonInt == 0 { - return "TVSpecial" - } - if strings.EqualFold(categoryOf(meta), "MOVIE") { - return "Movie" - } - return "TVSeries" -} - -func resolveMovieSource(meta api.PreparedMetadata) string { - switch strings.ToLower(strings.TrimSpace(meta.Source)) { - case "dvd": - return "DVD" - case "blu-ray", "bluray": - return "BluRay" - case "hdtv": - return "HDTV" - case "webrip", "webdl", "web": - return "WebRIP" - default: - return "BluRay" - } -} - -func resolveTVSource(meta api.PreparedMetadata) string { - switch strings.ToLower(strings.TrimSpace(meta.Source)) { - case "dvd": - return "DVD" - case "blu-ray", "bluray": - return "BluRay" - case "hdtv": - return "HDTV" - case "webrip", "webdl", "web": - return "WebRIP" - default: - return "HDTV" - } -} - -func resolveAnimeSource(meta api.PreparedMetadata) string { - switch strings.ToLower(strings.TrimSpace(meta.Source)) { - case "dvd": - return "DVD" - case "blu-ray", "bluray": - return "BluRay" - default: - return "Anime Series" - } -} - -func resolveAnimeVCodec(meta api.PreparedMetadata) string { - if strings.Contains(strings.ToLower(meta.VideoCodec), "vc-1") { - return "VC1" - } - if strings.Contains(strings.ToLower(meta.VideoEncode), "h.264") { - return "h264" - } - return "x264" -} - -func resolveName(meta api.PreparedMetadata) string { - if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { - return strings.TrimSpace(meta.SceneName) - } - return strings.ReplaceAll(metautil.FirstNonEmptyTrimmed(meta.ReleaseNameClean, meta.ReleaseName, meta.Filename), " ", ".") -} - -func resolveIMDbURL(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL) - } - if meta.ExternalIDs.IMDBID > 0 { - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) - } - return "" -} - -func resolvePoster(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Poster) - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Cover) - } - return "" -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.TrimSpace(meta.ExternalIDs.Category); category != "" { - return category - } - return strings.TrimSpace(meta.MediaInfoCategory) -} - -func isSD(meta api.PreparedMetadata) bool { - return strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "480p") || strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "576p") -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/fl/definition.go b/internal/trackers/impl/fl/definition.go deleted file mode 100644 index fbb4d76a..00000000 --- a/internal/trackers/impl/fl/definition.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package fl - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "FL" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(assets) - return trackers.DescriptionResult{Group: "fl", Description: description}, nil -} diff --git a/internal/trackers/impl/fl/upload.go b/internal/trackers/impl/fl/upload.go deleted file mode 100644 index e2d1e03e..00000000 --- a/internal/trackers/impl/fl/upload.go +++ /dev/null @@ -1,466 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package fl - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "maps" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/authmaterial" - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://filelist.io" - loginPageURL = baseURL + "/login.php" - loginURL = baseURL + "/takelogin.php" - uploadURL = baseURL + "/takeupload.php" - downloadURL = baseURL + "/download.php?id=" -) - -var ( - validatorPattern = regexp.MustCompile(`name="validator"\s+value="([^"]+)"`) - successPattern = regexp.MustCompile(`details\.php\?id=(\d+)&uploaded=`) - newHTTPClient = httpclient.New -) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - questionnaire *api.TrackerQuestionnaire - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req, false) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: FL %s", state.blockedReason) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ - FieldName: "file", - FileName: resolveTorrentFileName(req.Meta, state.releaseName) + ".torrent", - Path: state.torrentPath, - }}) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - jar, err := cookiejar.New(nil) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: FL create cookie jar: %w", err) - } - base, _ := url.Parse(baseURL) - jar.SetCookies(base, cookies) - client := httpclient.CloneWithTimeout(&http.Client{Jar: jar}, httpclient.DefaultTimeout) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: FL request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: FL upload request: %w", err) - } - defer resp.Body.Close() - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - responseBody, _ := io.ReadAll(resp.Body) - match := successPattern.FindStringSubmatch(finalURL) - if len(match) >= 2 { - id := match[1] - artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "FL") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := downloadPersonalizedTorrent(ctx, client, id, artifactPath); err != nil { - return api.UploadSummary{}, err - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "FL", - TorrentID: id, - TorrentURL: baseURL + "/details.php?id=" + id, - DownloadURL: downloadURL + id, - TorrentPath: artifactPath, - }}, - }, nil - } - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "FL", "upload_failure", responseBody, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("FL", resp.StatusCode, finalURL, responseBody) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req, true) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "FL", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "fl", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest, dryRun bool) (uploadState, []*http.Cookie, error) { - cookies, err := resolveCookies(ctx, req.Logger, req.TrackerConfig, req.AppConfig.MainSettings.DBPath, dryRun) - if err != nil { - return uploadState{}, nil, err - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(assets) - name := resolveName(req.Meta, questionnaireAnswers(req.Meta)) - fields := map[string]string{ - "name": name, - "type": strconv.Itoa(resolveCategoryID(req.Meta)), - "descr": description, - "nfo": resolveMedia(req.Meta), - } - if req.Meta.ExternalIDs.IMDBID > 0 { - fields["imdbid"] = strconv.Itoa(req.Meta.ExternalIDs.IMDBID) - fields["description"] = resolveGenres(req.Meta) - } - if strings.TrimSpace(req.TrackerConfig.UploaderName) != "" && !req.TrackerConfig.Anon { - fields["epenis"] = strings.TrimSpace(req.TrackerConfig.UploaderName) - } - if hasRomanianAudio(req.Meta) { - fields["materialro"] = "on" - } - if strings.EqualFold(strings.TrimSpace(req.Meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(req.Meta.Type), "REMUX") || req.Meta.TVPack { - fields["freeleech"] = "on" - } - state := uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: name, - fields: fields, - questionnaire: buildQuestionnaire(req.Meta, name), - } - if strings.TrimSpace(name) == "" { - state.blockedReason = "missing release name" - } - return state, cookies, nil -} - -// resolveCookies returns usable stored FL cookies or performs credential login. -// Login-page read errors are reported before token parsing, and successful -// credential login requires durable cookie persistence. -func resolveCookies(ctx context.Context, logger api.Logger, cfg config.TrackerConfig, dbPath string, dryRun bool) ([]*http.Cookie, error) { - loaded, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "FL", ".filelist.io") - if err != nil { - if logger != nil { - logger.Debugf("trackers: LoadTrackerHTTPCookies failed for FL/.filelist.io, dbPath=%s: %v", dbPath, err) - } - } else if valid := validFLCookies(loaded); len(valid) > 0 { - return valid, nil - } else if logger != nil { - logger.Debugf("trackers: FL loaded cookies were missing/expired, falling back to credential login, dbPath=%s", dbPath) - } - if dryRun { - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: FL cookies not found") - } - // #nosec G124 -- Dry-run sentinel is an outbound tracker jar cookie, not a browser-set cookie. - return []*http.Cookie{{Name: "dryrun", Value: "1", Domain: ".filelist.io", Path: "/"}}, nil - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: FL cookie invalid/missing and username/password not configured") - } - if err := ensureLoginCookieStorageAvailable(dbPath); err != nil { - return nil, err - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("trackers: FL create login cookie jar: %w", err) - } - client := newHTTPClient(httpclient.DefaultTimeout) - client.Jar = jar - req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginPageURL, nil) - if err != nil { - return nil, fmt.Errorf("trackers: FL login page request build: %w", err) - } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("trackers: FL login page request: %w", err) - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("trackers: FL read login page response: %w", err) - } - match := validatorPattern.FindStringSubmatch(string(body)) - if len(match) < 2 { - return nil, errors.New("trackers: FL validator token not found") - } - data := url.Values{} - data.Set("validator", match[1]) - data.Set("username", strings.TrimSpace(cfg.Username)) - data.Set("password", strings.TrimSpace(cfg.Password)) - data.Set("unlock", "1") - loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("trackers: FL login request build: %w", err) - } - loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - loginResp, err := client.Do(loginReq) - if err != nil { - return nil, fmt.Errorf("trackers: FL login request: %w", err) - } - defer loginResp.Body.Close() - if loginResp.StatusCode < 200 || loginResp.StatusCode >= 400 { - return nil, fmt.Errorf("trackers: FL login failed status=%d", loginResp.StatusCode) - } - base, _ := url.Parse(baseURL) - loginCookies := client.Jar.Cookies(base) - if err := persistLoginCookies(ctx, dbPath, loginCookies); err != nil { - return nil, err - } - return loginCookies, nil -} - -// persistLoginCookies saves FL login cookies and returns any persistence error -// so callers do not report login success without durable cookie storage. -func persistLoginCookies(ctx context.Context, dbPath string, values []*http.Cookie) error { - valid := validFLCookies(values) - if len(valid) == 0 { - return errors.New("trackers: FL login returned no usable cookies") - } - if err := cookies.SaveTrackerHTTPCookies(ctx, dbPath, "FL", valid); err != nil { - return fmt.Errorf("trackers: FL persist login cookies: %w", err) - } - return nil -} - -func ensureLoginCookieStorageAvailable(dbPath string) error { - if _, err := authmaterial.LoadFromDBPath(dbPath); err != nil { - if errors.Is(err, authmaterial.ErrUnavailable) { - return fmt.Errorf("trackers: FL encrypted cookie storage unavailable before credential login: %w", cookies.ErrAuthHelperUnavailable) - } - return fmt.Errorf("trackers: FL check encrypted cookie storage before credential login: %w", err) - } - return nil -} - -func validFLCookies(values []*http.Cookie) []*http.Cookie { - now := time.Now() - valid := make([]*http.Cookie, 0, len(values)) - for _, cookie := range values { - if cookie == nil { - continue - } - if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { - continue - } - if !cookie.Expires.IsZero() && cookie.Expires.Before(now) { - continue - } - valid = append(valid, cookie) - } - return valid -} - -func downloadPersonalizedTorrent(ctx context.Context, client *http.Client, id string, outputPath string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL+id, nil) - if err != nil { - return fmt.Errorf("trackers: FL torrent download request build: %w", err) - } - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("trackers: FL torrent download request: %w", err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("trackers: FL read torrent response: %w", err) - } - if err := os.WriteFile(outputPath, body, 0o600); err != nil { - return fmt.Errorf("trackers: FL write torrent output: %w", err) - } - return nil -} - -func buildDescription(assets trackers.DescriptionAssets) string { - return bbcode.FinalizeTrackerDescription("FL", strings.TrimSpace(assets.Description)) -} - -func buildQuestionnaire(meta api.PreparedMetadata, computedName string) *api.TrackerQuestionnaire { - answers := questionnaireAnswers(meta) - return &api.TrackerQuestionnaire{Tracker: "FL", Fields: []api.TrackerQuestionnaireField{{ - Key: "name", Label: "FileList Name", Kind: "text", Value: metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["name"]), computedName), Required: true, - }}} -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["FL"] -} - -func resolveName(meta api.PreparedMetadata, answers map[string]string) string { - if answers != nil && strings.TrimSpace(answers["name"]) != "" { - return strings.TrimSpace(answers["name"]) - } - name := strings.TrimSpace(meta.ReleaseName) - name = strings.ReplaceAll(name, " DV ", " DoVi ") - name = strings.ReplaceAll(name, "BluRay REMUX", "Remux") - name = strings.ReplaceAll(name, "BluRay Remux", "Remux") - name = strings.ReplaceAll(name, "PQ10", "HDR") - name = strings.ReplaceAll(name, "HDR10+", "HDR") - name = strings.ReplaceAll(name, "DD+", "DDP") - name = strings.Join(strings.Fields(name), ".") - return strings.Trim(name, ".") -} - -func resolveTorrentFileName(meta api.PreparedMetadata, releaseName string) string { - if meta.Anime && strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")), "SubsPlease") { - return releaseName - } - return strings.TrimSuffix(strings.TrimSpace(meta.Filename), filepath.Ext(strings.TrimSpace(meta.Filename))) -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - if meta.Anime { - return 24 - } - category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) - resolution := strings.TrimSpace(meta.Release.Resolution) - switch { - case strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD"): - if hasRomanianSub(meta) { - return 3 - } - return 2 - case category == "TV": - if resolution == "2160p" { - return 27 - } - if isSD(meta) { - return 23 - } - return 21 - default: - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { - if resolution == "2160p" { - return 26 - } - return 20 - } - if resolution == "2160p" { - return 6 - } - if isSD(meta) { - return 1 - } - if hasRomanianSub(meta) { - return 19 - } - return 4 - } -} - -func hasRomanianAudio(meta api.PreparedMetadata) bool { - for _, lang := range meta.AudioLanguages { - lower := strings.ToLower(strings.TrimSpace(lang)) - if lower == "romanian" || lower == "ro" { - return true - } - } - return false -} - -func hasRomanianSub(meta api.PreparedMetadata) bool { - for _, lang := range meta.SubtitleLanguages { - lower := strings.ToLower(strings.TrimSpace(lang)) - if lower == "romanian" || lower == "ro" { - return true - } - } - return false -} - -func resolveMedia(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - if summary, ok := meta.BDInfo["summary"].(string); ok { - return strings.TrimSpace(summary) - } - } - return metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)) -} - -func resolveGenres(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Genres) - } - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Genres) - } - return strings.TrimSpace(meta.Release.Genre) -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.TrimSpace(meta.ExternalIDs.Category); category != "" { - return category - } - return strings.TrimSpace(meta.MediaInfoCategory) -} - -func isSD(meta api.PreparedMetadata) bool { - resolution := strings.TrimSpace(meta.Release.Resolution) - return resolution == "480p" || resolution == "576p" -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/gpw/definition.go b/internal/trackers/impl/gpw/definition.go deleted file mode 100644 index ab73fdf7..00000000 --- a/internal/trackers/impl/gpw/definition.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package gpw - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "GPW" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "gpw", Description: description}, nil -} diff --git a/internal/trackers/impl/gpw/description_assets_test.go b/internal/trackers/impl/gpw/description_assets_test.go deleted file mode 100644 index 28c16e65..00000000 --- a/internal/trackers/impl/gpw/description_assets_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package gpw - -import ( - "context" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { - t.Parallel() - - assets := trackers.DescriptionAssets{ - Description: "Body token", - MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, - Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, - } - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "GPW", - AppConfig: config.Config{Description: config.DescriptionSettingsConfig{ - DiscMenuHeader: "Disc menu token", - ScreenshotHeader: "Screenshots token", - }}, - Assets: &assets, - }) - if err != nil { - t.Fatalf("build description: %v", err) - } - assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") - - final := trackers.DescriptionAssets{Description: " Authoritative final token ", Final: true, MenuImages: assets.MenuImages} - result, err = (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{Tracker: "GPW", Assets: &final}) - if err != nil { - t.Fatalf("build final description: %v", err) - } - if result.Description != "Authoritative final token" { - t.Fatalf("final description = %q", result.Description) - } -} - -func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { - t.Helper() - previous := -1 - for _, token := range tokens { - position := strings.Index(description, token) - if position <= previous { - t.Fatalf("description tokens out of order at %q: %q", token, description) - } - previous = position - } -} diff --git a/internal/trackers/impl/gpw/upload.go b/internal/trackers/impl/gpw/upload.go deleted file mode 100644 index 01690138..00000000 --- a/internal/trackers/impl/gpw/upload.go +++ /dev/null @@ -1,592 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package gpw - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "maps" - "net/http" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://greatposterwall.com" - torrentURL = baseURL + "/torrents.php?torrentid=" - sourceFlag = "GreatPosterWall" -) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - groupID string - questionnaire *api.TrackerQuestionnaire - blockedReason string -} - -type apiResponse struct { - Status any `json:"status"` - Response any `json:"response"` - Error string `json:"error"` - Message string `json:"message"` -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: GPW %s", state.blockedReason) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ - FieldName: "file_input", - FileName: "GPW.placeholder.torrent", - Path: state.torrentPath, - }}) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api.php?api_key="+req.TrackerConfig.APIKey+"&action=upload", bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: GPW upload request build: %s", commonhttp.RedactErrorDetail(err.Error())) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: GPW upload request: %w", err) - } - defer resp.Body.Close() - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 300, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: GPW read upload response: %w", err) - } - var decoded apiResponse - if err := json.Unmarshal(responseBody, &decoded); err != nil { - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return api.UploadSummary{}, commonhttp.UploadHTTPError("GPW", resp.StatusCode, responsePreview) - } - return api.UploadSummary{}, fmt.Errorf("trackers: GPW decode response: %w", err) - } - id := extractTorrentID(decoded.Response) - status := strings.ToLower(strings.TrimSpace(fmt.Sprint(decoded.Status))) - if (status == "success" || status == "ok" || status == "200") && id != "" { - tURL := torrentURL + id - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "GPW") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{Tracker: "GPW", TorrentID: id, TorrentURL: tURL, DownloadURL: tURL, TorrentPath: artifactPath}}}, nil - } - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "GPW", "upload_failure", responsePreview, ".json") - return api.UploadSummary{}, fmt.Errorf("trackers: GPW %s", metautil.FirstNonEmptyTrimmed(commonhttp.ExtractHTTPErrorDetail(responsePreview), commonhttp.RedactErrorDetail(decoded.Error), commonhttp.RedactErrorDetail(decoded.Message), "upload failed")) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.groupID == "" { - message += " for new group" - } else { - message += " for existing group" - } - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "GPW", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "gpw", - Description: state.description, - Endpoint: baseURL + "/api.php?api_key=" + req.TrackerConfig.APIKey + "&action=upload", - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "file_input", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { - return uploadState{}, errors.New("trackers: GPW missing api_key") - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - groupID, _ := lookupGroupID(ctx, req.TrackerConfig.APIKey, req.Meta) - answers := questionnaireAnswers(req.Meta) - fields := buildFields(req, req.TrackerConfig, description, groupID, answers) - state := uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), - fields: fields, - groupID: groupID, - questionnaire: buildQuestionnaire(req.Meta, groupID, answers), - } - if reason := validateFields(groupID, fields); reason != "" { - state.blockedReason = reason - } - return state, nil -} - -func lookupGroupID(ctx context.Context, apiKey string, meta api.PreparedMetadata) (string, error) { - if meta.ExternalIDs.IMDBID == 0 { - return "", nil - } - url := fmt.Sprintf("%s/api.php?api_key=%s&action=torrent&req=group&imdbID=tt%07d", baseURL, apiKey, meta.ExternalIDs.IMDBID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", fmt.Errorf("trackers: GPW torrent URL lookup request build: %s", commonhttp.RedactErrorDetail(err.Error())) - } - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) - if err != nil { - return "", fmt.Errorf("trackers: GPW torrent URL lookup request: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - var decoded apiResponse - if err := json.Unmarshal(body, &decoded); err != nil { - return "", nil - } - if responseMap, ok := decoded.Response.(map[string]any); ok { - if value, ok := responseMap["ID"]; ok { - return strings.TrimSpace(fmt.Sprint(value)), nil - } - } - return "", nil -} - -func buildFields(req trackers.UploadRequest, trackerCfg config.TrackerConfig, description string, groupID string, answers map[string]string) map[string]string { - meta := req.Meta - fields := map[string]string{ - "codec_other": "", - "codec": resolveCodec(meta), - "container_other": "", - "container": resolveContainer(meta), - "mediainfo[]": trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, meta), - "movie_edition_information": onOff(strings.TrimSpace(meta.Edition) != ""), - "processing_other": "", - "processing": resolveProcessing(meta), - "release_desc": description, - "remaster_custom_title": "", - "remaster_title": strings.TrimSpace(meta.Edition), - "remaster_year": "", - "resolution_height": "", - "resolution_width": "", - "resolution": resolveResolution(meta), - "source_other": "", - "source": resolveSource(meta), - "submit": "true", - "subtitle_type": resolveSubtitleType(meta), - "subtitles[]": strings.Join(resolveSubtitles(meta), ","), - } - if groupID != "" { - fields["groupid"] = groupID - } else { - fields["data_source"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["data_source"]), "imdb") - fields["identifier"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["identifier"]), resolveIdentifier(meta)) - fields["desc"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["desc"]), resolveOverview(meta)) - fields["image"] = strings.TrimSpace(answers["poster_url"]) - fields["maindesc"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["main_desc"]), resolveOverview(meta)) - fields["name"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["title"]), meta.Release.Title) - fields["releasetype"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["release_type"]), resolveMovieType(meta)) - fields["subname"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["subname"]), meta.Release.Title) - fields["tags"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["tags"]), resolveTags(meta)) - fields["year"] = strconv.Itoa(resolveYear(meta)) - fields["artists[]"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["director_name"]), resolveDirectorName(meta)) - fields["importance[]"] = "1" - fields["artist_ids[]"] = strings.TrimSpace(answers["director_imdb"]) - fields["artist_subs[]"] = strings.TrimSpace(answers["director_chinese"]) - fields["characters[]"] = "" - fields["main_artist_number"] = "1" - } - maps.Copy(fields, resolveMediaFlags(meta)) - if meta.Scene { - fields["scene"] = "on" - } - if meta.PersonalRelease { - if isDiscUpload(meta) { - fields["buy"] = "on" - } else { - fields["diy"] = "on" - } - } - if trackerCfg.Exclusive { - fields["jinzhuan"] = "on" - } - return fields -} - -func isDiscUpload(meta api.PreparedMetadata) bool { - return strings.TrimSpace(meta.DiscType) != "" || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") -} - -func buildQuestionnaire(meta api.PreparedMetadata, groupID string, answers map[string]string) *api.TrackerQuestionnaire { - if groupID != "" { - return nil - } - fields := []api.TrackerQuestionnaireField{ - {Key: "poster_url", Label: "Poster URL", Kind: "text", Value: metautil.FirstNonEmptyTrimmed(answers["poster_url"], resolvePoster(meta)), Required: true}, - {Key: "director_imdb", Label: "Director IMDb ID", Kind: "text", Value: answers["director_imdb"], Placeholder: "nm0000138", Required: true}, - {Key: "director_name", Label: "Director Name", Kind: "text", Value: metautil.FirstNonEmptyTrimmed(answers["director_name"], resolveDirectorName(meta)), Required: true}, - {Key: "director_chinese", Label: "Director Chinese", Kind: "text", Value: answers["director_chinese"]}, - {Key: "tags", Label: "Tags", Kind: "text", Value: metautil.FirstNonEmptyTrimmed(answers["tags"], resolveTags(meta)), Required: true}, - } - return &api.TrackerQuestionnaire{Tracker: "GPW", Fields: fields} -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["GPW"] -} - -func validateFields(groupID string, fields map[string]string) string { - if groupID == "" { - for _, key := range []string{"image", "artists[]", "artist_ids[]", "tags"} { - if strings.TrimSpace(fields[key]) == "" { - return "missing required new-group data" - } - } - } - return "" -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - if assets.Final { - return strings.TrimSpace(assets.Description) - } - meta := req.Meta - parts := make([]string, 0, 3) - // custom header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // logo - logoURL, logoSize := descriptionunit3d.ResolveLogo(meta, req.AppConfig) - if logoURL != "" { - if strings.HasSuffix(logoURL, ".svg") { - logoURL = strings.ReplaceAll(logoURL, ".svg", ".png") - } - parts = append(parts, fmt.Sprintf("[center][img=%d]%s[/img][/center]", logoSize, logoURL)) - } - - // description - if base := strings.TrimSpace(assets.Description); base != "" { - parts = append(parts, base) - } - - // menu - if len(assets.MenuImages) > 0 { - // header - if header := strings.TrimSpace(req.AppConfig.Description.DiscMenuHeader); header != "" { - parts = append(parts, header) - } - // images - if shots := screenshotBlock(assets.MenuImages); shots != "" { - parts = append(parts, shots) - } - } - // Screenshot Header - if header := strings.TrimSpace(req.AppConfig.Description.ScreenshotHeader); header != "" { - parts = append(parts, header) - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // screenshots - if shots := screenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - - // custom user signature - if signature := strings.TrimSpace(req.AppConfig.Description.CustomSignature); signature != "" { - parts = append(parts, signature) - } - - // upbrr signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("[align=right][url=%s][size=1]%s[/size][/url][/align]", link, text)) - - // finalize description - finalDescription := bbcode.FinalizeTrackerDescription("GPW", strings.TrimSpace(strings.Join(parts, "\n\n"))) - - // save debug description - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "GPW", req.AppConfig.MainSettings.DBPath, finalDescription, req.Logger) - } - - return finalDescription -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - parts := make([]string, 0, len(images)) - for _, image := range images { - if strings.TrimSpace(image.RawURL) == "" { - continue - } - parts = append(parts, "[img]"+strings.TrimSpace(image.RawURL)+"[/img]") - } - if len(parts) == 0 { - return "" - } - return "[center]" + strings.Join(parts, " ") + "[/center]" -} - -func extractTorrentID(value any) string { - switch typed := value.(type) { - case map[string]any: - if torrentID, ok := typed["torrent_id"]; ok { - return strings.TrimSpace(fmt.Sprint(torrentID)) - } - case []any: - if len(typed) > 0 { - if first, ok := typed[0].(map[string]any); ok { - if torrentID, ok := first["torrent_id"]; ok { - return strings.TrimSpace(fmt.Sprint(torrentID)) - } - } - } - } - return "" -} - -func resolveCodec(meta api.PreparedMetadata) string { - codec := strings.ToLower(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.VideoEncode, meta.VideoCodec))) - switch { - case strings.Contains(codec, "hevc"), strings.Contains(codec, "265"): - return "HEVC" - case strings.Contains(codec, "avc"), strings.Contains(codec, "264"): - return "AVC" - case strings.Contains(codec, "vc-1"): - return "VC-1" - default: - return "Other" - } -} - -func resolveContainer(meta api.PreparedMetadata) string { - container := strings.ToLower(strings.TrimSpace(meta.Container)) - switch container { - case "mkv", "mp4", "avi", "vob", "m2ts": - return strings.ToUpper(container) - default: - return "Other" - } -} - -func resolveProcessing(meta api.PreparedMetadata) string { - switch strings.ToUpper(strings.TrimSpace(meta.Type)) { - case "ENCODE": - return "Encode" - case "REMUX": - return "Remux" - case "DIY": - return "DIY" - default: - return "Untouched" - } -} - -func resolveResolution(meta api.PreparedMetadata) string { - resolution := strings.ToLower(strings.TrimSpace(meta.Release.Resolution)) - switch resolution { - case "480p", "576p", "720p", "1080i", "1080p", "2160p": - return resolution - default: - return "Other" - } -} - -func resolveSource(meta api.PreparedMetadata) string { - switch strings.ToUpper(strings.TrimSpace(meta.Type)) { - case "DISC": - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - return "Blu-ray" - } - return "DVD" - case "WEBDL", "WEBRIP": - return "WEB" - case "REMUX", "ENCODE": - return "Blu-ray" - case "HDTV": - return "HDTV" - default: - return "Other" - } -} - -func resolveSubtitleType(meta api.PreparedMetadata) string { - if len(meta.SubtitleLanguages) > 0 { - return "1" - } - return "3" -} - -func resolveSubtitles(meta api.PreparedMetadata) []string { - out := make([]string, 0, len(meta.SubtitleLanguages)) - for _, lang := range meta.SubtitleLanguages { - switch strings.ToLower(strings.TrimSpace(lang)) { - case "english", "en": - out = append(out, "English") - case "chinese", "zh": - out = append(out, "Chinese") - case "portuguese", "pt": - out = append(out, "Portuguese") - } - } - return out -} - -func resolveIdentifier(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID > 0 { - return fmt.Sprintf("tt%07d", meta.ExternalIDs.IMDBID) - } - if meta.ExternalIDs.TMDBID > 0 { - return strconv.Itoa(meta.ExternalIDs.TMDBID) - } - return "" -} - -func resolveOverview(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Overview) - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Plot) - } - return "" -} - -func resolvePoster(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Poster) - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Cover) - } - return "" -} - -func resolveMovieType(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil && meta.ExternalMetadata.IMDB.RuntimeMinutes > 0 && meta.ExternalMetadata.IMDB.RuntimeMinutes < 45 { - return "2" - } - return "1" -} - -func resolveTags(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(strings.ToLower(strings.ReplaceAll(meta.ExternalMetadata.TMDB.Genres, ", ", ","))) - } - return strings.TrimSpace(strings.ToLower(meta.Release.Genre)) -} - -func resolveDirectorName(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil && len(meta.ExternalMetadata.TMDB.Directors) > 0 { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Directors[0]) - } - if meta.ExternalMetadata.IMDB != nil && len(meta.ExternalMetadata.IMDB.Directors) > 0 { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Directors[0].Name) - } - return "" -} - -func resolveYear(meta api.PreparedMetadata) int { - if meta.ExternalMetadata.TMDB != nil && meta.ExternalMetadata.TMDB.Year > 0 { - return meta.ExternalMetadata.TMDB.Year - } - if meta.ExternalMetadata.IMDB != nil && meta.ExternalMetadata.IMDB.Year > 0 { - return meta.ExternalMetadata.IMDB.Year - } - return meta.Release.Year -} - -func resolveMediaFlags(meta api.PreparedMetadata) map[string]string { - flags := map[string]string{} - audio := strings.ToLower(strings.TrimSpace(meta.Audio)) - hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) - if strings.Contains(audio, "atmos") { - flags["dolby_atmos"] = "on" - } - if strings.Contains(audio, "dts:x") { - flags["dts_x"] = "on" - } - if meta.Channels == "5.1" { - flags["audio_51"] = "on" - } - if meta.Channels == "7.1" { - flags["audio_71"] = "on" - } - if meta.BitDepth == "10" && hdr == "" { - flags["10_bit"] = "on" - } - if strings.Contains(hdr, "DV") { - flags["dolby_vision"] = "on" - } - if strings.Contains(hdr, "HDR10+") { - flags["hdr10plus"] = "on" - } else if strings.Contains(hdr, "HDR") { - flags["hdr10"] = "on" - } - return flags -} - -func onOff(value bool) string { - if value { - return "on" - } - return "" -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/gpw/upload_test.go b/internal/trackers/impl/gpw/upload_test.go deleted file mode 100644 index 6d0b4042..00000000 --- a/internal/trackers/impl/gpw/upload_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package gpw - -import ( - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildFieldsPersonalReleaseAndExclusiveFlags(t *testing.T) { - t.Parallel() - - base := api.PreparedMetadata{PersonalRelease: true} - - nonDisc := buildFields(trackers.UploadRequest{Meta: base}, config.TrackerConfig{Exclusive: true}, "description", "group", nil) - if nonDisc["diy"] != "on" { - t.Fatalf("expected non-disc personal release to use diy flag, got %#v", nonDisc) - } - if _, ok := nonDisc["self_rip"]; ok { - t.Fatalf("did not expect legacy self_rip flag, got %#v", nonDisc) - } - if nonDisc["jinzhuan"] != "on" { - t.Fatalf("expected exclusive flag to set jinzhuan, got %#v", nonDisc) - } - - discMeta := base - discMeta.DiscType = "BDMV" - disc := buildFields(trackers.UploadRequest{Meta: discMeta}, config.TrackerConfig{}, "description", "group", nil) - if disc["buy"] != "on" { - t.Fatalf("expected disc personal release to use buy flag, got %#v", disc) - } - if _, ok := disc["diy"]; ok { - t.Fatalf("did not expect diy for disc personal release, got %#v", disc) - } -} diff --git a/internal/trackers/impl/hdb/definition.go b/internal/trackers/impl/hdb/definition.go deleted file mode 100644 index 66e83df6..00000000 --- a/internal/trackers/impl/hdb/definition.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdb - -import ( - "context" - "errors" - "fmt" - "strings" - - descriptionhdb "github.com/autobrr/upbrr/internal/services/description/hdb" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type Definition struct{} - -func New() *Definition { - return &Definition{} -} - -func (d *Definition) Name() string { - return "HDB" -} - -func (d *Definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (d *Definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (d *Definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - select { - case <-ctx.Done(): - return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - assets, err := resolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return trackers.DescriptionResult{}, err - } - if req.Logger != nil { - req.Logger.Warnf("trackers: HDB description assets failed: %v", err) - } - assets = trackers.DescriptionAssets{} - } - - description := strings.TrimSpace(assets.Description) - if !assets.Final { - description, err = descriptionhdb.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.MenuImages, assets.Screenshots) - if err != nil { - return trackers.DescriptionResult{}, fmt.Errorf("trackers: HDB description build: %w", err) - } - } - - if strings.TrimSpace(description) == "" && req.Logger != nil { - req.Logger.Infof("trackers: HDB preparation description empty") - } - - return trackers.DescriptionResult{ - Group: "hdb", - Description: description, - }, nil -} diff --git a/internal/trackers/impl/hdb/definition_test.go b/internal/trackers/impl/hdb/definition_test.go deleted file mode 100644 index 4c882647..00000000 --- a/internal/trackers/impl/hdb/definition_test.go +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdb - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestDefinitionBuildDescriptionUsesHDBGroup(t *testing.T) { - d := New() - result, err := d.BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "HDB", - Meta: api.PreparedMetadata{}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Group != "hdb" { - t.Fatalf("expected hdb group, got %q", result.Group) - } -} - -func TestDefinitionBuildDescriptionUsesProvidedAssets(t *testing.T) { - d := New() - result, err := d.BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "HDB", - Meta: api.PreparedMetadata{}, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - Description: "kept description", - Screenshots: []api.ScreenshotImage{{ - ImgURL: "https://t.hdbits.org/example.jpg", - WebURL: "https://img.hdbits.org/example", - RawURL: "https://img.hdbits.org/example", - }}, - }, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result.Description, "https://t.hdbits.org/example.jpg") { - t.Fatalf("expected provided screenshot in description, got %q", result.Description) - } -} - -func TestDefinitionBuildDescriptionUsesProvidedMenuImages(t *testing.T) { - d := New() - result, err := d.BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "HDB", - Meta: api.PreparedMetadata{}, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - MenuImages: []api.ScreenshotImage{{ - Purpose: api.ScreenshotPurposeMenu, - ImgURL: "https://t.hdbits.org/menu.jpg", - WebURL: "https://img.hdbits.org/menu", - }}, - }, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result.Description, "https://t.hdbits.org/menu.jpg") { - t.Fatalf("expected provided menu image in description, got %q", result.Description) - } -} - -func TestDefinitionUploadMissingCredentials(t *testing.T) { - d := New() - _, err := d.Upload(context.Background(), trackers.UploadRequest{Tracker: "HDB", Logger: api.NopLogger{}}) - if err == nil { - t.Fatal("expected missing credentials error") - } -} - -func TestDefinitionUploadSuccess(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - dbPath := filepath.Join(tmp, "ua.db") - cookieDir := filepath.Join(tmp, "cookies") - if err := os.MkdirAll(cookieDir, 0o755); err != nil { - t.Fatalf("mkdir cookie dir: %v", err) - } - cookieText := "# Netscape HTTP Cookie File\n.hdbits.org\tTRUE\t/\tTRUE\t0\tuid\tcookievalue\n" - if err := os.WriteFile(filepath.Join(cookieDir, "HDB.txt"), []byte(cookieText), 0o600); err != nil { - t.Fatalf("write cookie file: %v", err) - } - - uploadSeen := false - downloadSeen := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/upload/upload": - uploadSeen = true - if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "multipart/form-data") { - t.Errorf("expected multipart content-type, got %q", ct) - return - } - if err := r.ParseMultipartForm(5 << 20); err != nil { - t.Errorf("parse multipart: %v", err) - return - } - if r.FormValue("name") == "" { - t.Error("expected upload name field") - return - } - if descr := r.FormValue("descr"); !strings.Contains(descr, "https://t.hdbits.org/rehosted.jpg") { - t.Errorf("expected provided rehosted screenshot in description, got %q", descr) - return - } - files := r.MultipartForm.File["file"] - if len(files) == 0 { - t.Error("expected torrent file in multipart form") - return - } - f, err := files[0].Open() - if err != nil { - t.Errorf("open uploaded file: %v", err) - return - } - _, _ = io.ReadAll(f) - _ = f.Close() - http.Redirect(w, r, "/details.php?id=123&uploaded=1", http.StatusFound) - case r.URL.Path == "/details.php": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - case strings.HasPrefix(r.URL.Path, "/download.php/"): - downloadSeen = true - if r.URL.Query().Get("id") != "123" { - t.Errorf("expected id=123, got %q", r.URL.Query().Get("id")) - return - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("reseed-torrent-bytes")) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - d := New() - result, err := d.Upload(context.Background(), trackers.UploadRequest{ - Tracker: "HDB", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - VideoCodec: "HEVC", - ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - Username: "user", - Passkey: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - Description: "kept description", - Screenshots: []api.ScreenshotImage{{ - ImgURL: "https://t.hdbits.org/rehosted.jpg", - WebURL: "https://img.hdbits.org/rehosted", - RawURL: "https://img.hdbits.org/rehosted", - }}, - }, - }) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected uploaded=1, got %d", result.Uploaded) - } - if len(result.UploadedTorrents) != 1 { - t.Fatalf("expected one uploaded torrent artifact, got %d", len(result.UploadedTorrents)) - } - if result.UploadedTorrents[0].Tracker != "HDB" { - t.Fatalf("expected tracker HDB, got %q", result.UploadedTorrents[0].Tracker) - } - if result.UploadedTorrents[0].TorrentID != "123" { - t.Fatalf("expected torrent id 123, got %q", result.UploadedTorrents[0].TorrentID) - } - if !uploadSeen { - t.Fatal("expected upload endpoint to be called") - } - if !downloadSeen { - t.Fatal("expected download endpoint to be called") - } - artifactPath := result.UploadedTorrents[0].TorrentPath - if strings.TrimSpace(artifactPath) == "" { - t.Fatal("expected tracker-specific torrent path") - } - updated, err := os.ReadFile(artifactPath) - if err != nil { - t.Fatalf("read updated torrent: %v", err) - } - if string(updated) != "reseed-torrent-bytes" { - t.Fatalf("expected downloaded torrent bytes, got %q", string(updated)) - } - original, err := os.ReadFile(torrentPath) - if err != nil { - t.Fatalf("read original torrent: %v", err) - } - if string(original) != "dummy" { - t.Fatalf("expected original torrent to remain unchanged, got %q", string(original)) - } -} - -func TestDefinitionBuildUploadDryRunUsesProvidedAssets(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "Movie.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - entry, err := buildUploadDryRun(context.Background(), trackers.UploadRequest{ - Tracker: "HDB", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - VideoCodec: "HEVC", - ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", - }, - TrackerConfig: config.TrackerConfig{ - Username: "user", - Passkey: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}, - Logger: api.NopLogger{}, - Assets: &trackers.DescriptionAssets{ - Description: "kept description", - Screenshots: []api.ScreenshotImage{{ - ImgURL: "https://t.hdbits.org/dryrun.jpg", - WebURL: "https://img.hdbits.org/dryrun", - RawURL: "https://img.hdbits.org/dryrun", - }}, - }, - }) - if err != nil { - t.Fatalf("unexpected dry-run error: %v", err) - } - if !strings.Contains(entry.Description, "https://t.hdbits.org/dryrun.jpg") { - t.Fatalf("expected provided screenshot in dry-run description, got %q", entry.Description) - } -} - -func TestBuildUploadFieldsSkipsTVDBForMovie(t *testing.T) { - fields := buildUploadFields(api.PreparedMetadata{ - MediaInfoCategory: "TV", - ExternalIDs: api.ExternalIDs{Category: "MOVIE", TVDBID: 765432}, - }, config.Config{}, 1, 5, 6, "description") - - if _, ok := fields["tvdb"]; ok { - t.Fatalf("did not expect tvdb for movie payload") - } - if _, ok := fields["tvdb_season"]; ok { - t.Fatalf("did not expect tvdb_season for movie payload") - } - if _, ok := fields["tvdb_episode"]; ok { - t.Fatalf("did not expect tvdb_episode for movie payload") - } -} - -func TestBuildUploadFieldsIncludesTVDBForTV(t *testing.T) { - fields := buildUploadFields(api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "TV", TVDBID: 765432}, - SeasonInt: 2, - EpisodeInt: 3, - }, config.Config{}, 2, 5, 6, "description") - - if got := fields["tvdb"]; got != "765432" { - t.Fatalf("expected tvdb=765432, got %q", got) - } - if got := fields["tvdb_season"]; got != "2" { - t.Fatalf("expected tvdb_season=2, got %q", got) - } - if got := fields["tvdb_episode"]; got != "3" { - t.Fatalf("expected tvdb_episode=3, got %q", got) - } -} diff --git a/internal/trackers/impl/hdb/upload.go b/internal/trackers/impl/hdb/upload.go deleted file mode 100644 index b4d599d7..00000000 --- a/internal/trackers/impl/hdb/upload.go +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdb - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - cookiepkg "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/paths" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/services/db" - descriptionhdb "github.com/autobrr/upbrr/internal/services/description/hdb" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - hdbBaseURL = "https://hdbits.org" - hdbUploadPath = "/upload/upload" -) - -var hdbSuccessURLPattern = regexp.MustCompile(`(?i)details\.php\?id=(\d+)&uploaded=\d+`) - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - select { - case <-ctx.Done(): - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - username := strings.TrimSpace(req.TrackerConfig.Username) - passkey := strings.TrimSpace(req.TrackerConfig.Passkey) - if username == "" || passkey == "" { - return api.UploadSummary{}, errors.New("trackers: HDB missing username/passkey") - } - - category := hdbCategoryID(req.Meta) - codec := hdbCodecID(req.Meta) - medium := hdbMediumID(req.Meta) - if category == 0 || codec == 0 || medium == 0 { - return api.UploadSummary{}, fmt.Errorf("trackers: HDB mapping failed category=%d codec=%d medium=%d", category, codec, medium) - } - - assets, err := resolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return api.UploadSummary{}, err - } - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - descriptionText := strings.TrimSpace(assets.Description) - if !assets.Final { - descriptionText, err = descriptionhdb.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.MenuImages, assets.Screenshots) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.UploadSummary{}, err - } - - uploadURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if uploadURL == "" { - uploadURL = hdbBaseURL - } - uploadURL += hdbUploadPath - - cookies, err := resolveHDBCookies(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.UploadSummary{}, err - } - - fields := buildUploadFields(req.Meta, req.AppConfig, category, codec, medium, descriptionText) - body, contentType, err := buildMultipartPayload(fields, torrentPath) - if err != nil { - return api.UploadSummary{}, err - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDB request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - for _, cookie := range cookies { - httpReq.AddCookie(cookie) - } - - client := &http.Client{Timeout: 40 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDB upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - matches := hdbSuccessURLPattern.FindStringSubmatch(finalURL) - if len(matches) < 2 { - bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("HDB", resp.StatusCode, finalURL, bodyPreview) - } - - torrentID := strings.TrimSpace(matches[1]) - trackerTorrentPath := "" - if torrentID != "" { - trackerTorrentPath, err = resolveTrackerTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath, "HDB") - if err != nil { - return api.UploadSummary{}, err - } - if err := downloadPersonalizedTorrent(ctx, uploadURL, req.Meta, trackerTorrentPath, torrentID, passkey, cookies); err != nil && req.Logger != nil { - req.Logger.Warnf("trackers: HDB torrent redownload failed: %v", err) - } - } - - downloadURL := buildHDBDownloadURL(uploadURL, req.Meta, torrentID, passkey) - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "HDB", - TorrentID: torrentID, - DownloadURL: downloadURL, - TorrentPath: trackerTorrentPath, - }}, - }, nil -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - select { - case <-ctx.Done(): - return api.TrackerDryRunEntry{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - username := strings.TrimSpace(req.TrackerConfig.Username) - passkey := strings.TrimSpace(req.TrackerConfig.Passkey) - if username == "" || passkey == "" { - return api.TrackerDryRunEntry{}, errors.New("trackers: HDB missing username/passkey") - } - - category := hdbCategoryID(req.Meta) - codec := hdbCodecID(req.Meta) - medium := hdbMediumID(req.Meta) - if category == 0 || codec == 0 || medium == 0 { - return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: HDB mapping failed category=%d codec=%d medium=%d", category, codec, medium) - } - - assets, err := resolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return api.TrackerDryRunEntry{}, err - } - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - descriptionText := strings.TrimSpace(assets.Description) - if !assets.Final { - descriptionText, err = descriptionhdb.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.MenuImages, assets.Screenshots) - if err != nil { - return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: %w", err) - } - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - - uploadURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if uploadURL == "" { - uploadURL = hdbBaseURL - } - uploadURL += hdbUploadPath - - fields := buildUploadFields(req.Meta, req.AppConfig, category, codec, medium, descriptionText) - - return api.TrackerDryRunEntry{ - Tracker: "HDB", - Status: "ready", - Message: "dry-run payload generated", - ReleaseName: resolveUploadName(req.Meta), - DescriptionGroup: "hdb", - Description: descriptionText, - Endpoint: uploadURL, - Payload: fields, - Files: []api.TrackerDryRunFile{{ - Field: "file", - Path: torrentPath, - Present: strings.TrimSpace(torrentPath) != "", - }}, - }, nil -} - -func buildUploadFields(meta api.PreparedMetadata, appConfig config.Config, categoryID int, codecID int, mediumID int, description string) map[string]string { - fields := map[string]string{ - "name": resolveUploadName(meta), - "category": strconv.Itoa(categoryID), - "codec": strconv.Itoa(codecID), - "medium": strconv.Itoa(mediumID), - "origin": "0", - "descr": strings.TrimSpace(description), - "techinfo": "", - } - - if trackers.IsInternalGroup(appConfig, "HDB", meta) { - fields["origin"] = "1" - } - - if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - if strings.TrimSpace(meta.MediaInfoTextPath) != "" { - content, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) - if err == nil { - fields["techinfo"] = strings.TrimSpace(string(content)) - } - } - } - - if isHDBTVCategory(meta) && meta.ExternalIDs.TVDBID != 0 { - fields["tvdb"] = strconv.Itoa(meta.ExternalIDs.TVDBID) - } - if imdb := resolveIMDbURL(meta); imdb != "" { - fields["imdb"] = imdb - } else { - fields["imdb"] = "0" - } - if isHDBTVCategory(meta) { - season := meta.SeasonInt - episode := meta.EpisodeInt - if season <= 0 { - season = 1 - } - if episode <= 0 { - episode = 1 - } - fields["tvdb_season"] = strconv.Itoa(season) - fields["tvdb_episode"] = strconv.Itoa(episode) - } - - return fields -} - -// isHDBTVCategory reports whether HDB upload payloads may include TVDB fields. -// Explicit movie categories suppress TVDB fields even when MediaInfo or overrides classify the release as TV. -func isHDBTVCategory(meta api.PreparedMetadata) bool { - if isHDBMovieCategory(meta) { - return false - } - if strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "TV") || - strings.EqualFold(strings.TrimSpace(meta.MediaInfoCategory), "TV") || - strings.EqualFold(strings.TrimSpace(meta.Release.Category), "TV") { - return true - } - if meta.ReleaseNameOverrides.Category != nil { - return strings.EqualFold(strings.TrimSpace(*meta.ReleaseNameOverrides.Category), "TV") - } - return false -} - -func isHDBMovieCategory(meta api.PreparedMetadata) bool { - if strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "MOVIE") || - strings.EqualFold(strings.TrimSpace(meta.MediaInfoCategory), "MOVIE") || - strings.EqualFold(strings.TrimSpace(meta.Release.Category), "MOVIE") { - return true - } - if meta.ReleaseNameOverrides.Category != nil { - return strings.EqualFold(strings.TrimSpace(*meta.ReleaseNameOverrides.Category), "MOVIE") - } - return false -} - -func resolveDescriptionAssets( - ctx context.Context, - tracker string, - meta api.PreparedMetadata, - repo db.MetadataRepository, - logger api.Logger, - provided *trackers.DescriptionAssets, -) (trackers.DescriptionAssets, error) { - if provided != nil { - return *provided, nil - } - return wrapTrackerResult(trackers.ResolveDescriptionAssets(ctx, tracker, meta, repo, logger)) -} - -func resolveUploadName(meta api.PreparedMetadata) string { - if name := strings.TrimSpace(meta.ReleaseName); name != "" { - return name - } - if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { - return name - } - if name := strings.TrimSpace(meta.Filename); name != "" { - return name - } - return pathutil.Base(meta.SourcePath) -} - -func resolveIMDbURL(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - if value := strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL); value != "" { - if !strings.HasSuffix(value, "/") { - return value + "/" - } - return value - } - } - if meta.ExternalIDs.IMDBID != 0 { - return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.ExternalIDs.IMDBID) - } - return "" -} - -func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - - for key, value := range fields { - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: HDB write multipart field %q: %w", key, err) - } - } - - file, err := os.Open(torrentPath) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: HDB open torrent file: %w", err) - } - defer file.Close() - - part, err := writer.CreateFormFile("file", filepath.Base(torrentPath)) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: HDB create torrent form file: %w", err) - } - if _, err := io.Copy(part, file); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: HDB copy torrent file: %w", err) - } - - if err := writer.Close(); err != nil { - return nil, "", fmt.Errorf("trackers: HDB close multipart writer: %w", err) - } - return body.Bytes(), writer.FormDataContentType(), nil -} - -func resolveTorrentPath(meta api.PreparedMetadata, dbPath string) (string, error) { - candidates := []string{ - strings.TrimSpace(meta.TorrentPath), - strings.TrimSpace(meta.ClientTorrentPath), - strings.TrimSpace(meta.SourcePath), - } - for _, candidate := range candidates { - if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { - continue - } - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate, nil - } - } - - if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err == nil { - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err == nil { - guessed := filepath.Join(tmpDir, base+".torrent") - if info, err := os.Stat(guessed); err == nil && !info.IsDir() { - return guessed, nil - } - } - } - } - - return "", errors.New("trackers: HDB torrent file not found") -} - -func resolveTrackerTorrentPath(meta api.PreparedMetadata, dbPath string, tracker string) (string, error) { - if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { - return "", errors.New("trackers: HDB tracker torrent path requires db path and source path") - } - - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: HDB tmp root: %w", err) - } - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: HDB tmp release dir: %w", err) - } - name := strings.ToLower(strings.TrimSpace(tracker)) - name = strings.NewReplacer("/", "-", "\\", "-", " ", "-").Replace(name) - if name == "" { - name = "tracker" - } - return filepath.Join(tmpDir, base+"."+name+".torrent"), nil -} - -func resolveHDBCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { - return wrapTrackerResult(cookiepkg.LoadTrackerHTTPCookies(ctx, dbPath, "HDB", "hdbits.org")) -} - -func downloadPersonalizedTorrent(ctx context.Context, uploadURL string, meta api.PreparedMetadata, torrentPath string, torrentID string, passkey string, cookies []*http.Cookie) error { - downloadURL := buildHDBDownloadURL(uploadURL, meta, torrentID, passkey) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return fmt.Errorf("trackers: HDB create personalized torrent request: %w", err) - } - for _, cookie := range cookies { - req.AddCookie(cookie) - } - - client := &http.Client{Timeout: 40 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("trackers: HDB download personalized torrent: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("trackers: HDB read personalized torrent response: %w", err) - } - if len(body) == 0 { - return errors.New("empty torrent response") - } - if err := os.MkdirAll(filepath.Dir(torrentPath), 0o700); err != nil { - return fmt.Errorf("trackers: HDB create torrent output dir: %w", err) - } - if err := os.WriteFile(torrentPath, body, 0o600); err != nil { - return fmt.Errorf("trackers: HDB write torrent output: %w", err) - } - return nil -} - -func buildHDBDownloadURL(uploadURL string, meta api.PreparedMetadata, torrentID string, passkey string) string { - if strings.TrimSpace(torrentID) == "" || strings.TrimSpace(passkey) == "" { - return "" - } - base := strings.TrimSuffix(uploadURL, hdbUploadPath) - filePart := pathutil.Base(meta.SourcePath) - if filePart == "" || filePart == "." || filePart == string(filepath.Separator) { - filePart = "download" - } - return fmt.Sprintf("%s/download.php/%s?id=%s&passkey=%s", strings.TrimRight(base, "/"), url.PathEscape(filePart), url.QueryEscape(torrentID), url.QueryEscape(passkey)) -} - -func hdbCategoryID(meta api.PreparedMetadata) int { - category := strings.ToUpper(strings.TrimSpace(meta.ExternalIDs.Category)) - if category == "" { - category = strings.ToUpper(strings.TrimSpace(meta.MediaInfoCategory)) - } - if category == "" && meta.ReleaseNameOverrides.Category != nil { - category = strings.ToUpper(strings.TrimSpace(*meta.ReleaseNameOverrides.Category)) - } - switch category { - case "MOVIE": - return 1 - case "TV": - return 2 - } - genres := "" - keywords := "" - if meta.ExternalMetadata.TMDB != nil { - genres = strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.TMDB.Genres)) - keywords = strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords)) - } - if strings.Contains(genres, "documentary") || strings.Contains(keywords, "documentary") { - return 3 - } - if meta.ExternalMetadata.IMDB != nil { - imdbType := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.IMDB.Type)) - imdbGenres := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.IMDB.Genres)) - if strings.Contains(imdbType, "concert") || (strings.Contains(imdbType, "video") && strings.Contains(imdbGenres, "music")) { - return 4 - } - } - return 0 -} - -func hdbCodecID(meta api.PreparedMetadata) int { - codec := strings.ToUpper(strings.TrimSpace(meta.VideoCodec)) - if codec == "" { - codec = strings.ToUpper(strings.TrimSpace(meta.VideoEncode)) - } - switch codec { - case "AVC", "H.264": - return 1 - case "MPEG-2": - return 2 - case "VC-1": - return 3 - case "XVID": - return 4 - case "HEVC", "H.265": - return 5 - case "VP9": - return 6 - default: - return 0 - } -} - -func hdbMediumID(meta api.PreparedMetadata) int { - discType := strings.ToUpper(strings.TrimSpace(meta.DiscType)) - contentType := resolveHDBType(meta) - if discType == "BDMV" || discType == "HD DVD" { - return 1 - } - if contentType == "HDTV" { - if meta.HasEncodeSettings { - return 3 - } - return 4 - } - switch contentType { - case "ENCODE", "WEBRIP": - return 3 - case "REMUX": - return 5 - case "WEBDL": - return 6 - default: - return 0 - } -} - -func resolveHDBType(meta api.PreparedMetadata) string { - typeValue := normalizeHDBType(meta.Type) - if typeValue == "" || isHDBCategoryType(typeValue) { - if meta.ReleaseNameOverrides.Type != nil { - typeValue = normalizeHDBType(*meta.ReleaseNameOverrides.Type) - } - } - if typeValue == "" || isHDBCategoryType(typeValue) { - typeValue = normalizeHDBType(meta.Release.Type) - } - if typeValue == "" || isHDBCategoryType(typeValue) { - if strings.TrimSpace(meta.DiscType) != "" { - typeValue = "DISC" - } - } - if typeValue == "" || isHDBCategoryType(typeValue) { - typeValue = inferHDBTypeFromSource(meta.Source) - } - if typeValue == "" || isHDBCategoryType(typeValue) { - typeValue = inferHDBTypeFromPath(meta.SourcePath) - } - if typeValue == "" || isHDBCategoryType(typeValue) { - if strings.TrimSpace(meta.VideoEncode) != "" { - typeValue = "ENCODE" - } - } - if typeValue == "" || isHDBCategoryType(typeValue) { - if strings.TrimSpace(meta.VideoCodec) != "" || strings.TrimSpace(meta.Release.Resolution) != "" || strings.TrimSpace(meta.Release.Ext) != "" { - typeValue = "ENCODE" - } - } - return typeValue -} - -func normalizeHDBType(value string) string { - upper := strings.ToUpper(strings.TrimSpace(value)) - if upper == "" { - return "" - } - upper = strings.ReplaceAll(upper, "-", "") - upper = strings.ReplaceAll(upper, " ", "") - upper = strings.ReplaceAll(upper, "_", "") - switch upper { - case "WEBDL": - return "WEBDL" - case "WEBRIP": - return "WEBRIP" - } - return upper -} - -func isHDBCategoryType(value string) bool { - upper := strings.ToUpper(strings.TrimSpace(value)) - return upper == "MOVIE" || upper == "TV" -} - -func inferHDBTypeFromSource(source string) string { - upper := normalizeHDBType(source) - switch { - case strings.Contains(upper, "WEBDL"): - return "WEBDL" - case strings.Contains(upper, "WEBRIP"): - return "WEBRIP" - case strings.Contains(upper, "HDTV"): - return "HDTV" - } - return "" -} - -func inferHDBTypeFromPath(path string) string { - base := strings.ToUpper(strings.TrimSpace(path)) - compact := strings.NewReplacer(".", "", "-", "", "_", "", " ", "").Replace(base) - switch { - case strings.Contains(compact, "REMUX"): - return "REMUX" - case strings.Contains(compact, "WEBDL"): - return "WEBDL" - case strings.Contains(compact, "WEBRIP"): - return "WEBRIP" - case strings.Contains(compact, "HDTV"): - return "HDTV" - case strings.Contains(compact, "DVDRIP"): - return "DVDRIP" - } - return "" -} diff --git a/internal/trackers/impl/hdb/wrap_errors.go b/internal/trackers/impl/hdb/wrap_errors.go deleted file mode 100644 index 4d817827..00000000 --- a/internal/trackers/impl/hdb/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdb - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/hds/definition.go b/internal/trackers/impl/hds/definition.go deleted file mode 100644 index bbf6083e..00000000 --- a/internal/trackers/impl/hds/definition.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hds - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "HDS" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "hds", Description: description}, nil -} diff --git a/internal/trackers/impl/hds/description_assets_test.go b/internal/trackers/impl/hds/description_assets_test.go deleted file mode 100644 index 6ee67fe8..00000000 --- a/internal/trackers/impl/hds/description_assets_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hds - -import ( - "context" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { - t.Parallel() - - assets := trackers.DescriptionAssets{ - Description: "Body token", - MenuImages: []api.ScreenshotImage{{ - RawURL: "https://images.example.invalid/menu.png", - ImgURL: "https://images.example.invalid/menu-thumb.png", - WebURL: "https://images.example.invalid/menu-view", - }}, - Screenshots: []api.ScreenshotImage{{ - RawURL: "https://images.example.invalid/normal.png", - ImgURL: "https://images.example.invalid/normal-thumb.png", - WebURL: "https://images.example.invalid/normal-view", - }}, - } - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "HDS", - AppConfig: config.Config{Description: config.DescriptionSettingsConfig{ - DiscMenuHeader: "Disc menu token", - ScreenshotHeader: "Screenshots token", - }}, - Assets: &assets, - }) - if err != nil { - t.Fatalf("build description: %v", err) - } - assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu-thumb.png", "Screenshots token", "normal-thumb.png") - - final := trackers.DescriptionAssets{Description: " Authoritative final token ", Final: true, MenuImages: assets.MenuImages} - result, err = (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{Tracker: "HDS", Assets: &final}) - if err != nil { - t.Fatalf("build final description: %v", err) - } - if result.Description != "Authoritative final token" { - t.Fatalf("final description = %q", result.Description) - } -} - -func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { - t.Helper() - previous := -1 - for _, token := range tokens { - position := strings.Index(description, token) - if position <= previous { - t.Fatalf("description tokens out of order at %q: %q", token, description) - } - previous = position - } -} diff --git a/internal/trackers/impl/hds/upload.go b/internal/trackers/impl/hds/upload.go deleted file mode 100644 index 39da1b0e..00000000 --- a/internal/trackers/impl/hds/upload.go +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hds - -import ( - "bytes" - "context" - "fmt" - "maps" - "net/http" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://hd-space.org" - uploadURL = baseURL + "/index.php?page=upload" - torrentURL = baseURL + "/index.php?page=torrent-details&id=" - sourceFlag = "HD-Space" -) - -var idPattern = regexp.MustCompile(`download\.php\?id=([a-zA-Z0-9]+)`) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - nfo *commonhttp.FileField - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: HDS %s", state.blockedReason) - } - files := []commonhttp.FileField{{ - FieldName: "torrent", - FileName: filepath.Base(state.torrentPath), - Path: state.torrentPath, - }} - if state.nfo != nil { - files = append(files, *state.nfo) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDS request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDS upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 400, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDS read upload response: %w", err) - } - combined := finalURL + "\n" + string(responseBody) - match := idPattern.FindStringSubmatch(combined) - if resp.StatusCode >= 200 && resp.StatusCode < 400 && len(match) >= 2 { - id := strings.TrimSpace(match[1]) - tURL := torrentURL + id - artifactPath := "" - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "HDS") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "HDS", - TorrentID: id, - TorrentURL: tURL, - DownloadURL: baseURL + "/download.php?id=" + id, - TorrentPath: artifactPath, - }}, - }, nil - } - - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "HDS", "upload_failure", responsePreview, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("HDS", resp.StatusCode, responsePreview) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "HDS", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "hds", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{Field: "torrent", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, []*http.Cookie, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - cookies, err := loadCookies(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, err - } - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - fields := map[string]string{ - "category": strconv.Itoa(resolveCategoryID(req.Meta)), - "filename": metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Filename, pathutil.Base(req.Meta.SourcePath)), - "genre": resolveGenres(req.Meta), - "imdb": resolveIMDbURL(req.Meta), - "info": description, - "nuk_rea": "", - "nuk": "false", - "req": "false", - "submit": "Send", - "t3d": boolString(req.Meta.Is3D != ""), - "user_id": "", - "youtube_video": resolveYouTube(req.Meta), - "anonymous": boolString(req.TrackerConfig.Anon), - } - state := uploadState{torrentPath: torrentPath, description: description, releaseName: fields["filename"], fields: fields} - if !supportsHDSResolution(req.Meta.Release.Resolution) { - state.blockedReason = "resolution must be at least 720p" - } - if id := resolveIMDbURL(req.Meta); strings.TrimSpace(id) == "" { - state.blockedReason = "missing IMDb ID" - } - if file, ok := resolveNFO(req.Meta); ok { - state.nfo = &file - } - return state, cookies, nil -} - -func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { - return wrapTrackerResult(cookies.LoadTrackerHTTPCookies(ctx, dbPath, "HDS", "hd-space.org")) -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - return 15 - } - if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { - return 40 - } - category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) - if strings.Contains(strings.ToLower(resolveGenres(meta)+" "+resolveKeywords(meta)), "documentary") { - if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "2160p") { - return 47 - } - if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "1080p") || strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "1080i") { - return 25 - } - return 24 - } - if meta.Anime { - switch strings.TrimSpace(meta.Release.Resolution) { - case "2160p": - return 48 - case "1080p", "1080i": - return 28 - default: - return 27 - } - } - if category == "TV" { - switch strings.TrimSpace(meta.Release.Resolution) { - case "2160p": - return 45 - case "1080p", "1080i": - return 22 - default: - return 21 - } - } - switch strings.TrimSpace(meta.Release.Resolution) { - case "2160p": - return 46 - case "1080p", "1080i": - return 19 - default: - return 18 - } -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - if assets.Final { - return strings.TrimSpace(assets.Description) - } - meta := req.Meta - parts := make([]string, 0, 12) - - // Custom Header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // Logo - if req.AppConfig.Description.AddLogo { - if logo := resolveLogo(meta); logo != "" { - parts = append(parts, "[center][img]"+logo+"[/img][/center]") - } - } - - // TV Episode details - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") - } - - // File information (BDInfo or MediaInfo) - if media := trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, meta); media != "" { - parts = append(parts, "[pre]"+media+"[/pre]") - } - - // User description - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - - // menu - if len(assets.MenuImages) > 0 { - // header - if header := strings.TrimSpace(req.AppConfig.Description.DiscMenuHeader); header != "" { - parts = append(parts, header) - } - // images - if shots := screenshotBlock(assets.MenuImages); shots != "" { - parts = append(parts, shots) - } - } - // Screenshot Header - if header := strings.TrimSpace(req.AppConfig.Description.ScreenshotHeader); header != "" { - parts = append(parts, header) - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // screenshots - if shots := screenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - - // custom user signature - if signature := strings.TrimSpace(req.AppConfig.Description.CustomSignature); signature != "" { - parts = append(parts, signature) - } - - // upbrr signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("[center][url=%s][size=2]%s[/size][/url][/center]", link, text)) - - // finalize description - finalDescription := bbcode.FinalizeTrackerDescription("HDS", strings.TrimSpace(strings.Join(parts, "\n\n"))) - - // save debug description - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "HDS", req.AppConfig.MainSettings.DBPath, finalDescription, req.Logger) - } - - return finalDescription -} - -func resolveLogo(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil && strings.TrimSpace(meta.ExternalMetadata.TMDB.TMDBLogo) != "" { - return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ExternalMetadata.TMDB.TMDBLogo), "/") - } - return "" -} - -func resolveGenres(meta api.PreparedMetadata) string { - switch { - case meta.ExternalMetadata.TMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Genres) - case meta.ExternalMetadata.IMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Genres) - default: - return strings.TrimSpace(meta.Release.Genre) - } -} - -func resolveKeywords(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) - } - return "" -} - -func resolveIMDbURL(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil && strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL) != "" { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL) - } - if meta.ExternalIDs.IMDBID > 0 { - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) - } - return "" -} - -func resolveYouTube(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.YouTube) - } - return "" -} - -func resolveNFO(meta api.PreparedMetadata) (commonhttp.FileField, bool) { - dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) - payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") - if err != nil { - return commonhttp.FileField{}, false - } - return commonhttp.FileField{FieldName: "nfo", FileName: filepath.Base(path), Content: payload}, true -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.TrimSpace(meta.ExternalIDs.Category); category != "" { - return category - } - return strings.TrimSpace(meta.MediaInfoCategory) -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - var sb strings.Builder - for _, image := range images { - if strings.TrimSpace(image.WebURL) == "" || strings.TrimSpace(image.ImgURL) == "" { - continue - } - sb.WriteString("[url=" + image.WebURL + "][img]" + image.ImgURL + "[/img][/url]") - - // HDS cannot resize images. If the image host does not provide small thumbnails(<400px), place only one image per line. - // imgbox provides small thumbnails, so we can place them side-by-side. - if !strings.Contains(strings.ToLower(image.WebURL), "imgbox") { - sb.WriteString("\n") - } - } - content := strings.TrimSpace(sb.String()) - if content == "" { - return "" - } - return "[center]\n" + content + "\n[/center]" -} - -func boolString(value bool) string { - if value { - return "true" - } - return "false" -} - -func supportsHDSResolution(value string) bool { - switch strings.TrimSpace(value) { - case "2160p", "1080p", "1080i", "720p": - return true - default: - return false - } -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/hds/upload_test.go b/internal/trackers/impl/hds/upload_test.go deleted file mode 100644 index 22334676..00000000 --- a/internal/trackers/impl/hds/upload_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hds - -import "testing" - -func TestSupportsHDSResolution(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - resolution string - expected bool - }{ - {name: "2160p", resolution: "2160p", expected: true}, - {name: "1080i", resolution: "1080i", expected: true}, - {name: "720p", resolution: "720p", expected: true}, - {name: "576p", resolution: "576p", expected: false}, - {name: "480p", resolution: "480p", expected: false}, - } - - for _, tc := range cases { - if got := supportsHDSResolution(tc.resolution); got != tc.expected { - t.Fatalf("%s: expected %t, got %t", tc.name, tc.expected, got) - } - } -} diff --git a/internal/trackers/impl/hds/wrap_errors.go b/internal/trackers/impl/hds/wrap_errors.go deleted file mode 100644 index 010bdc92..00000000 --- a/internal/trackers/impl/hds/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hds - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/hdt/definition.go b/internal/trackers/impl/hdt/definition.go deleted file mode 100644 index bc5fff19..00000000 --- a/internal/trackers/impl/hdt/definition.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdt - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "HDT" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "hdt", Description: description}, nil -} diff --git a/internal/trackers/impl/hdt/description_assets_test.go b/internal/trackers/impl/hdt/description_assets_test.go deleted file mode 100644 index 7afa8c4f..00000000 --- a/internal/trackers/impl/hdt/description_assets_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdt - -import ( - "context" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { - t.Parallel() - - assets := trackers.DescriptionAssets{ - Description: "Body token", - MenuImages: []api.ScreenshotImage{{ - RawURL: "https://images.example.invalid/menu.png", - ImgURL: "https://images.example.invalid/menu-thumb.png", - }}, - Screenshots: []api.ScreenshotImage{{ - RawURL: "https://images.example.invalid/normal.png", - ImgURL: "https://images.example.invalid/normal-thumb.png", - }}, - } - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "HDT", - AppConfig: config.Config{Description: config.DescriptionSettingsConfig{ - DiscMenuHeader: "Disc menu token", - ScreenshotHeader: "Screenshots token", - }}, - Assets: &assets, - }) - if err != nil { - t.Fatalf("build description: %v", err) - } - assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") - - final := trackers.DescriptionAssets{Description: " Authoritative final token ", Final: true, MenuImages: assets.MenuImages} - result, err = (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{Tracker: "HDT", Assets: &final}) - if err != nil { - t.Fatalf("build final description: %v", err) - } - if result.Description != "Authoritative final token" { - t.Fatalf("final description = %q", result.Description) - } -} - -func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { - t.Helper() - previous := -1 - for _, token := range tokens { - position := strings.Index(description, token) - if position <= previous { - t.Fatalf("description tokens out of order at %q: %q", token, description) - } - previous = position - } -} diff --git a/internal/trackers/impl/hdt/upload.go b/internal/trackers/impl/hdt/upload.go deleted file mode 100644 index 5f64b6a2..00000000 --- a/internal/trackers/impl/hdt/upload.go +++ /dev/null @@ -1,444 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdt - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "maps" - "net/http" - "net/url" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -var tokenPattern = regexp.MustCompile(`name="csrfToken"\s+value="([^"]+)"`) -var successPattern = regexp.MustCompile(`details\.php\?id=([a-zA-Z0-9]+)|Upload successful!`) -var detailsPattern = regexp.MustCompile(`details\.php\?id=([a-zA-Z0-9]+)`) - -type uploadState struct { - baseURL string - torrentPath string - description string - releaseName string - fields map[string]string - nfo *commonhttp.FileField - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req, false) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: HDT %s", state.blockedReason) - } - files := []commonhttp.FileField{{FieldName: "torrent", FileName: filepath.Base(state.torrentPath), Path: state.torrentPath}} - if state.nfo != nil { - files = append(files, *state.nfo) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.baseURL+"/upload.php", bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDT request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDT upload request: %w", err) - } - defer resp.Body.Close() - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 400, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: HDT read upload response: %w", err) - } - combined := finalURL + "\n" + string(responseBody) - id := "" - if match := detailsPattern.FindStringSubmatch(combined); len(match) >= 2 { - id = match[1] - } - if resp.StatusCode >= 200 && resp.StatusCode < 400 && successPattern.MatchString(combined) { - tURL := finalURL - if id != "" && !strings.Contains(tURL, "details.php?id=") { - tURL = state.baseURL + "/details.php?id=" + id - } - artifactPath := "" - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "HDT") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, "hd-torrents.org"); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "HDT", - TorrentID: id, - TorrentURL: tURL, - DownloadURL: tURL, - TorrentPath: artifactPath, - }}, - }, nil - } - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "HDT", "upload_failure", responsePreview, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("HDT", resp.StatusCode, responsePreview) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req, true) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "HDT", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "hdt", - Description: state.description, - Endpoint: state.baseURL + "/upload.php", - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{Field: "torrent", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest, dryRun bool) (uploadState, []*http.Cookie, error) { - base := resolveBaseURL(req.TrackerConfig.URL) - cookies, err := loadCookies(ctx, req.AppConfig.MainSettings.DBPath, base) - if err != nil { - return uploadState{}, nil, err - } - token := strings.Join([]string{"dry", "run", "token"}, "-") - if !dryRun { - token, err = fetchToken(ctx, base, cookies) - if err != nil { - return uploadState{}, nil, err - } - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - fields := map[string]string{ - "filename": resolveName(req.Meta), - "category": strconv.Itoa(resolveCategoryID(req.Meta)), - "info": description, - "csrfToken": token, - "season": boolString(req.Meta.TVPack), - "anonymous": boolString(req.TrackerConfig.Anon), - } - if req.Meta.Is3D != "" { - fields["3d"] = "true" - } - hdr := strings.ToUpper(strings.TrimSpace(req.Meta.HDR)) - if strings.Contains(hdr, "HDR10+") { - fields["HDR10"] = "true" - fields["HDR10Plus"] = "true" - } else if strings.Contains(hdr, "HDR") { - fields["HDR10"] = "true" - } - if strings.Contains(hdr, "DV") { - fields["DolbyVision"] = "true" - } - if imdb := resolveIMDbURL(req.Meta); imdb != "" { - fields["infosite"] = imdb + "/" - } - state := uploadState{ - baseURL: base, - torrentPath: torrentPath, - description: description, - releaseName: fields["filename"], - fields: fields, - } - if strings.TrimSpace(req.Meta.Release.Resolution) == "" { - state.blockedReason = "missing resolution" - } - if file, ok := resolveNFO(req.Meta); ok { - state.nfo = &file - } - return state, cookies, nil -} - -func resolveBaseURL(configURL string) string { - trimmed := strings.TrimSpace(configURL) - if trimmed == "" { - return "https://hd-torrents.me" - } - parsed, err := url.Parse(trimmed) - if err == nil && parsed.Host != "" { - return "https://" + parsed.Host - } - return strings.TrimRight(trimmed, "/") -} - -func loadCookies(ctx context.Context, dbPath string, baseURL string) ([]*http.Cookie, error) { - host := "hd-torrents.me" - if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" { - host = parsed.Host - } - return wrapTrackerResult(cookies.LoadTrackerHTTPCookies(ctx, dbPath, "HDT", host)) -} - -func fetchToken(ctx context.Context, baseURL string, cookies []*http.Cookie) (string, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/upload.php", nil) - if err != nil { - return "", fmt.Errorf("trackers: HDT token request build: %w", err) - } - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return "", fmt.Errorf("trackers: HDT token request: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - match := tokenPattern.FindStringSubmatch(string(body)) - if len(match) < 2 { - return "", errors.New("trackers: HDT csrf token not found") - } - return strings.TrimSpace(match[1]), nil -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) - resolution := strings.TrimSpace(meta.Release.Resolution) - if category == "TV" { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") { - if resolution == "2160p" { - return 72 - } - return 59 - } - if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { - if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && resolution == "2160p" { - return 73 - } - return 60 - } - switch resolution { - case "2160p": - return 65 - case "1080p", "1080i": - return 30 - default: - return 38 - } - } - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") { - if resolution == "2160p" { - return 70 - } - return 1 - } - if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { - if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && resolution == "2160p" { - return 71 - } - return 2 - } - switch resolution { - case "2160p": - return 64 - case "1080p", "1080i": - return 5 - default: - return 3 - } -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - if assets.Final { - return strings.TrimSpace(assets.Description) - } - meta := req.Meta - parts := make([]string, 0, 15) - - // Custom Header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // Logo - if req.AppConfig.Description.AddLogo { - if logo := resolveLogo(meta); logo != "" { - parts = append(parts, "[center][img]"+logo+"[/img][/center]") - } - } - - // TV Episode details - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") - } - - // File information (BDInfo or MediaInfo) - if media := trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, meta); media != "" { - parts = append(parts, "[pre]"+media+"[/pre]") - } - - // User description - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - - // menu - if len(assets.MenuImages) > 0 { - // header - if header := strings.TrimSpace(req.AppConfig.Description.DiscMenuHeader); header != "" { - parts = append(parts, header) - } - // images - if shots := screenshotBlock(assets.MenuImages); shots != "" { - parts = append(parts, shots) - } - } - // Screenshot Header - if header := strings.TrimSpace(req.AppConfig.Description.ScreenshotHeader); header != "" { - parts = append(parts, header) - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // screenshots - if shots := screenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - - // custom user signature - if signature := strings.TrimSpace(req.AppConfig.Description.CustomSignature); signature != "" { - parts = append(parts, signature) - } - - // upbrr signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("[right][url=%s][size=1]%s[/size][/url][/right]", link, text)) - - // finalize description - finalDescription := bbcode.FinalizeTrackerDescription("HDT", strings.TrimSpace(strings.Join(parts, "\n\n"))) - - // save debug description - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "HDT", req.AppConfig.MainSettings.DBPath, finalDescription, req.Logger) - } - - return finalDescription -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - parts := make([]string, 0, len(images)) - for _, image := range images { - if strings.TrimSpace(image.RawURL) == "" { - continue - } - parts = append(parts, " ") - } - if len(parts) == 0 { - return "" - } - return "[center]" + strings.Join(parts, " ") + "[/center]" -} - -func resolveName(meta api.PreparedMetadata) string { - name := strings.TrimSpace(meta.ReleaseName) - if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") || strings.EqualFold(strings.TrimSpace(meta.Type), "WEBRIP") || strings.EqualFold(strings.TrimSpace(meta.Type), "ENCODE") { - name = strings.Replace(name, meta.Audio, strings.Replace(meta.Audio, " ", "", 1), 1) - } - name = strings.ReplaceAll(name, " DV ", " DoVi ") - name = strings.ReplaceAll(name, "BluRay REMUX", "Blu-ray Remux") - name = strings.Join(strings.Fields(name), " ") - name = strings.ReplaceAll(name, ":", "") - return strings.TrimSpace(name) -} - -func resolveLogo(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil && strings.TrimSpace(meta.ExternalMetadata.TMDB.TMDBLogo) != "" { - return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ExternalMetadata.TMDB.TMDBLogo), "/") - } - return "" -} - -func resolveIMDbURL(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL) - } - if meta.ExternalIDs.IMDBID > 0 { - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) - } - return "" -} - -func resolveNFO(meta api.PreparedMetadata) (commonhttp.FileField, bool) { - dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) - payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") - if err != nil { - return commonhttp.FileField{}, false - } - return commonhttp.FileField{FieldName: "nfos", FileName: filepath.Base(path), Content: payload}, true -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.TrimSpace(meta.ExternalIDs.Category); category != "" { - return category - } - return strings.TrimSpace(meta.MediaInfoCategory) -} - -func boolString(value bool) string { - if value { - return "true" - } - return "false" -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/hdt/wrap_errors.go b/internal/trackers/impl/hdt/wrap_errors.go deleted file mode 100644 index 4abd447a..00000000 --- a/internal/trackers/impl/hdt/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package hdt - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/is/definition.go b/internal/trackers/impl/is/definition.go deleted file mode 100644 index 9a283aee..00000000 --- a/internal/trackers/impl/is/definition.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package is - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { - return definition{} -} - -func (definition) Name() string { - return "IS" -} - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "is", Description: description}, nil -} diff --git a/internal/trackers/impl/is/description_assets_test.go b/internal/trackers/impl/is/description_assets_test.go deleted file mode 100644 index 791b0c86..00000000 --- a/internal/trackers/impl/is/description_assets_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package is - -import ( - "context" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { - t.Parallel() - - assets := trackers.DescriptionAssets{ - Description: "Body token", - MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, - Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, - } - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "IS", - AppConfig: config.Config{Description: config.DescriptionSettingsConfig{ - DiscMenuHeader: "Disc menu token", - }}, - Assets: &assets, - }) - if err != nil { - t.Fatalf("build description: %v", err) - } - assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots:", "normal.png") - - final := trackers.DescriptionAssets{Description: " Authoritative final token ", Final: true, MenuImages: assets.MenuImages} - result, err = (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{Tracker: "IS", Assets: &final}) - if err != nil { - t.Fatalf("build final description: %v", err) - } - if result.Description != "Authoritative final token" { - t.Fatalf("final description = %q", result.Description) - } -} - -func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { - t.Helper() - previous := -1 - for _, token := range tokens { - position := strings.Index(description, token) - if position <= previous { - t.Fatalf("description tokens out of order at %q: %q", token, description) - } - previous = position - } -} diff --git a/internal/trackers/impl/is/upload.go b/internal/trackers/impl/is/upload.go deleted file mode 100644 index e23e55b8..00000000 --- a/internal/trackers/impl/is/upload.go +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package is - -import ( - "bytes" - "context" - "fmt" - "maps" - "net/http" - "path/filepath" - "regexp" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://immortalseed.me" - uploadURL = baseURL + "/upload.php" - torrentURL = baseURL + "/details.php?hash=" - sourceFlag = "https://immortalseed.me" -) - -var ( - sslPattern = regexp.MustCompile(`details\.php\?hash=([a-zA-Z0-9]+)|download\.php\?id=([a-zA-Z0-9]+)`) - successTexts = []string{"Download Torrent (SSL)", "Thank you for uploading"} -) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - nfo *commonhttp.FileField - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: IS %s", state.blockedReason) - } - - files := []commonhttp.FileField{{ - FieldName: "torrentfile", - FileName: metautil.FirstNonEmptyTrimmed(state.releaseName, filepath.Base(state.torrentPath)), - Path: state.torrentPath, - }} - if state.nfo != nil { - files = append(files, *state.nfo) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: IS request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: IS upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 400, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: IS read upload response: %w", err) - } - id, success := successfulUploadResponse(finalURL, string(responseBody)) - if resp.StatusCode >= 200 && resp.StatusCode < 400 && success { - if id != "" { - tURL := torrentURL + id - artifactPath := "" - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "IS") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "IS", - TorrentID: id, - TorrentURL: tURL, - DownloadURL: tURL, - TorrentPath: artifactPath, - }}, - }, nil - } - return api.UploadSummary{Uploaded: 1}, nil - } - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "IS", "upload_failure", responsePreview, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("IS", resp.StatusCode, responsePreview) -} - -func successfulUploadResponse(finalURL string, responseBody string) (string, bool) { - match := sslPattern.FindStringSubmatch(finalURL + "\n" + responseBody) - if len(match) >= 3 { - if id := metautil.FirstNonEmptyTrimmed(match[1], match[2]); id != "" { - return id, true - } - } - for _, text := range successTexts { - if strings.Contains(responseBody, text) { - return "", true - } - } - return "", false -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "IS", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "is", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{Field: "torrentfile", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, []*http.Cookie, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - cookies, err := loadCookies(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, err - } - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - fields := map[string]string{ - "UseNFOasDescr": "no", - "message": buildMessage(req.Meta), - "category": strconv.Itoa(resolveCategoryID(req.Meta)), - "subject": resolveSubject(req.Meta), - "nothingtopost": "1", - "t_image_url": resolvePoster(req.Meta), - "submit": "Upload Torrent", - "anonymous": yesNo(req.TrackerConfig.Anon), - } - if strings.EqualFold(categoryOf(req.Meta), "MOVIE") { - fields["t_link"] = resolveIMDbURL(req.Meta) - } - state := uploadState{torrentPath: torrentPath, description: description, releaseName: fields["subject"], fields: fields} - if strings.TrimSpace(fields["t_image_url"]) == "" { - state.blockedReason = "missing poster URL" - } - if file, ok := resolveNFO(req.Meta); ok { - state.nfo = &file - } - return state, cookies, nil -} - -func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { - return wrapTrackerResult(cookies.LoadTrackerHTTPCookies(ctx, dbPath, "IS", "immortalseed.me")) -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - if assets.Final { - return strings.TrimSpace(assets.Description) - } - meta := req.Meta - parts := make([]string, 0, 8) - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "Title: "+strings.TrimSpace(meta.EpisodeTitle), "Overview: "+strings.TrimSpace(meta.EpisodeOverview)) - } - if media := trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, meta); media != "" { - parts = append(parts, media) - } - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - if len(assets.MenuImages) > 0 { - var menuLines []string - if header := strings.TrimSpace(req.AppConfig.Description.DiscMenuHeader); header != "" { - menuLines = append(menuLines, header) - } - for _, image := range assets.MenuImages { - if strings.TrimSpace(image.RawURL) != "" { - menuLines = append(menuLines, image.RawURL) - } - } - if len(menuLines) > 0 { - parts = append(parts, strings.Join(menuLines, "\n")) - } - } - if len(assets.Screenshots) > 0 { - var shotLines []string - for _, image := range assets.Screenshots { - if strings.TrimSpace(image.RawURL) != "" { - shotLines = append(shotLines, image.RawURL) - } - } - if len(shotLines) > 0 { - parts = append(parts, "Screenshots:\n"+strings.Join(shotLines, "\n")) - } - } - return bbcode.FinalizeTrackerDescription("IS", strings.TrimSpace(strings.Join(parts, "\n\n"))) -} - -func buildMessage(meta api.PreparedMetadata) string { - message := strings.TrimSpace(resolveOverview(meta)) - if trailer := resolveYouTube(meta); trailer != "" { - if message != "" { - message += "\n\n" - } - message += "[youtube]" + trailer + "[/youtube]" - } - return message -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) - resolution := strings.TrimSpace(meta.Release.Resolution) - genres := strings.ToLower(resolveGenres(meta) + " " + resolveKeywords(meta)) - nonEnglish := !hasEnglishAudio(meta) - switch category { - case "TV": - if strings.Contains(genres, "documentary") { - if isSD(meta) { - return 13 - } - return 15 - } - if meta.Anime { - return 6 - } - if strings.Contains(genres, "children") || strings.Contains(genres, "cartoon") { - return 5 - } - if meta.TVPack { - if resolution == "2160p" { - return 63 - } - if isSD(meta) { - return 6 - } - return 4 - } - if resolution == "2160p" { - return 64 - } - if resolution == "1080p" || resolution == "1080i" || resolution == "720p" { - return 8 - } - if isSD(meta) { - if strings.Contains(strings.ToLower(meta.VideoEncode), "xvid") { - return 9 - } - return 48 - } - return 47 - default: - if strings.Contains(genres, "documentary") { - if isSD(meta) { - return 13 - } - return 15 - } - if meta.Anime { - return 6 - } - if resolution == "2160p" { - if nonEnglish { - return 60 - } - return 62 - } - if !isSD(meta) { - if nonEnglish { - return 18 - } - return 16 - } - if isSD(meta) { - if nonEnglish { - return 33 - } - return 14 - } - if nonEnglish { - return 34 - } - return 17 - } -} - -func hasEnglishAudio(meta api.PreparedMetadata) bool { - for _, language := range meta.AudioLanguages { - lower := strings.ToLower(strings.TrimSpace(language)) - if lower == "english" || lower == "en" { - return true - } - } - return false -} - -func resolveSubject(meta api.PreparedMetadata) string { - if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { - return strings.TrimSpace(meta.SceneName) - } - name := strings.TrimSpace(meta.ReleaseName) - name = strings.ReplaceAll(name, strings.TrimSpace(meta.Release.Alt), "") - name = strings.ReplaceAll(name, "Dubbed", "") - name = strings.ReplaceAll(name, "Dual-Audio", "") - name = strings.Join(strings.Fields(name), ".") - return strings.Trim(name, ".") -} - -func resolvePoster(meta api.PreparedMetadata) string { - switch { - case meta.ExternalMetadata.TMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Poster) - case meta.ExternalMetadata.IMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Cover) - case meta.ExternalMetadata.TVmaze != nil: - return strings.TrimSpace(meta.ExternalMetadata.TVmaze.Poster) - default: - return "" - } -} - -func resolveIMDbURL(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL) - } - if meta.ExternalIDs.IMDBID > 0 { - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) - } - return "" -} - -func resolveOverview(meta api.PreparedMetadata) string { - switch { - case meta.ExternalMetadata.TMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Overview) - case meta.ExternalMetadata.IMDB != nil: - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Plot) - case meta.ExternalMetadata.TVmaze != nil: - return strings.TrimSpace(meta.ExternalMetadata.TVmaze.Summary) - default: - return strings.TrimSpace(meta.EpisodeOverview) - } -} - -func resolveYouTube(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.YouTube) - } - return "" -} - -func resolveGenres(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Genres) - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Genres) - } - return strings.TrimSpace(meta.Release.Genre) -} - -func resolveKeywords(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) - } - return "" -} - -func resolveNFO(meta api.PreparedMetadata) (commonhttp.FileField, bool) { - dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) - payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") - if err != nil { - return commonhttp.FileField{}, false - } - return commonhttp.FileField{FieldName: "nfofile", FileName: filepath.Base(path), Content: payload}, true -} - -func isSD(meta api.PreparedMetadata) bool { - resolution := strings.TrimSpace(meta.Release.Resolution) - return resolution == "" || resolution == "480p" || resolution == "576p" -} - -func categoryOf(meta api.PreparedMetadata) string { - if category := strings.TrimSpace(meta.ExternalIDs.Category); category != "" { - return category - } - return strings.TrimSpace(meta.MediaInfoCategory) -} - -func yesNo(value bool) string { - if value { - return "yes" - } - return "no" -} - -func cloneFields(in map[string]string) map[string]string { - out := make(map[string]string, len(in)) - maps.Copy(out, in) - return out -} diff --git a/internal/trackers/impl/is/wrap_errors.go b/internal/trackers/impl/is/wrap_errors.go deleted file mode 100644 index 954f2cb9..00000000 --- a/internal/trackers/impl/is/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package is - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/manifest_locality_test.go b/internal/trackers/impl/manifest_locality_test.go new file mode 100644 index 00000000..0101fcec --- /dev/null +++ b/internal/trackers/impl/manifest_locality_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + + imagehostpolicy "github.com/autobrr/upbrr/internal/imagehosting/policy" + "github.com/autobrr/upbrr/internal/trackers" +) + +func TestTrackerImplementationsUseFamilyRoots(t *testing.T) { + t.Parallel() + + allowed := map[string]struct{}{ + "azfamily": {}, + "commonhttp": {}, + "standalone": {}, + "unit3d": {}, + } + root := filepath.Join(trackerManifestRepoRoot(t), "internal", "trackers", "impl") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read tracker implementation root: %v", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, ok := allowed[entry.Name()]; !ok { + t.Errorf("tracker implementation directory must be under a family root: %s", entry.Name()) + } + } +} + +func TestStandaloneRegistryMatchesPackageDirectories(t *testing.T) { + t.Parallel() + + registry := MustNewRegistry() + want := make(map[string]struct{}) + for _, name := range registry.NamesByFamily(trackers.FamilyStandalone) { + want[strings.ToLower(name)] = struct{}{} + } + + root := filepath.Join(trackerManifestRepoRoot(t), "internal", "trackers", "impl", "standalone") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read standalone implementation root: %v", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if entry.Name() == "internal" { + continue + } + if _, err := os.Stat(filepath.Join(root, entry.Name(), "profile.go")); err != nil { + t.Errorf("standalone tracker package must own profile.go: tracker=%s error=%v", entry.Name(), err) + } + if _, ok := want[entry.Name()]; !ok { + t.Errorf("unregistered standalone tracker directory: %s", entry.Name()) + continue + } + delete(want, entry.Name()) + } + for name := range want { + t.Errorf("standalone registry entry has no package directory: %s", name) + } +} + +func TestGenericTrackerPolicyFilesContainNoSupportedTrackerDispatch(t *testing.T) { + t.Parallel() + + registry := MustNewRegistry() + supported := make(map[string]struct{}, len(registry.Names())) + for _, name := range registry.Names() { + supported[strings.ToUpper(name)] = struct{}{} + } + repoRoot := trackerManifestRepoRoot(t) + paths := []string{ + "internal/metadata/media_details.go", + "internal/metadata/source_lookup.go", + "internal/metadata/tracker_data.go", + "internal/torrentclient/search.go", + } + for _, directory := range []string{"internal/trackers/auth", "internal/imagehosting/policy"} { + err := filepath.WalkDir(filepath.Join(repoRoot, filepath.FromSlash(directory)), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("walk generic tracker policy path: %w", walkErr) + } + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") { + relative, err := filepath.Rel(repoRoot, path) + if err != nil { + return fmt.Errorf("resolve generic tracker policy path: %w", err) + } + paths = append(paths, filepath.ToSlash(relative)) + } + return nil + }) + if err != nil { + t.Fatalf("scan %s: %v", directory, err) + } + } + for _, relative := range paths { + ignored := map[string]struct{}{} + if strings.HasPrefix(relative, "internal/imagehosting/policy/") { + for _, host := range imagehostpolicy.KnownUploadHosts() { + ignored[strings.ToUpper(host)] = struct{}{} + } + } + assertNoSupportedTrackerStringLiterals(t, repoRoot, relative, supported, ignored) + } + for _, relative := range []string{ + "webui/src/hooks/useSettingsState.tsx", + "webui/src/pages/settings/index.tsx", + } { + assertNoSupportedTrackerTypeScriptLiterals(t, repoRoot, relative, supported) + } +} + +func TestUnit3DBaseURLsRemainInSiteProfiles(t *testing.T) { + t.Parallel() + + repoRoot := trackerManifestRepoRoot(t) + sitesRoot := filepath.Join(repoRoot, "internal", "trackers", "impl", "unit3d", "sites") + err := filepath.WalkDir(sitesRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("walk Unit3D site path: %w", walkErr) + } + if entry.IsDir() || entry.Name() == "profile.go" || strings.HasSuffix(entry.Name(), "_test.go") || !strings.HasSuffix(entry.Name(), ".go") { + return nil + } + relative, err := filepath.Rel(repoRoot, path) + if err != nil { + return fmt.Errorf("resolve Unit3D site path: %w", err) + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return fmt.Errorf("parse Unit3D site source: %w", err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err == nil && (strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://")) { + t.Errorf("Unit3D endpoint literal outside profile: file=%s value=%s", filepath.ToSlash(relative), value) + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("scan Unit3D site profiles: %v", err) + } +} + +func trackerManifestRepoRoot(t *testing.T) string { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve manifest-locality test path") + } + return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) +} + +func assertNoSupportedTrackerStringLiterals( + t *testing.T, + repoRoot string, + relative string, + supported map[string]struct{}, + ignored map[string]struct{}, +) { + t.Helper() + path := filepath.Join(repoRoot, filepath.FromSlash(relative)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", relative, err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err == nil { + normalized := strings.ToUpper(strings.TrimSpace(value)) + _, allowed := ignored[normalized] + if _, found := supported[normalized]; found && !allowed { + t.Errorf("generic tracker dispatch literal: file=%s value=%q", relative, value) + } + } + return true + }) +} + +func assertNoSupportedTrackerTypeScriptLiterals(t *testing.T, repoRoot string, relative string, supported map[string]struct{}) { + t.Helper() + payload, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(relative))) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + quoted := regexp.MustCompile(`["']([A-Za-z0-9]+)["']`) + for _, match := range quoted.FindAllSubmatch(payload, -1) { + if _, found := supported[strings.ToUpper(string(match[1]))]; found { + t.Errorf("generic frontend tracker dispatch literal: file=%s value=%q", relative, match[1]) + } + } +} diff --git a/internal/trackers/impl/mtv/definition.go b/internal/trackers/impl/mtv/definition.go deleted file mode 100644 index 783c08e7..00000000 --- a/internal/trackers/impl/mtv/definition.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package mtv - -import ( - "context" - "errors" - "fmt" - "strings" - - descriptionmtv "github.com/autobrr/upbrr/internal/services/description/mtv" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type Definition struct{} - -func New() *Definition { - return &Definition{} -} - -func (d *Definition) Name() string { - return "MTV" -} - -func (d *Definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (d *Definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (d *Definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - select { - case <-ctx.Done(): - return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - var err error - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) - } - if req.Logger != nil { - req.Logger.Warnf("trackers: MTV description assets failed: %v", err) - } - assets = trackers.DescriptionAssets{} - } - } - - description := strings.TrimSpace(assets.Description) - if !assets.Final { - description, err = descriptionmtv.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.Screenshots) - if err != nil { - return trackers.DescriptionResult{}, fmt.Errorf("trackers: MTV description build: %w", err) - } - } - - if strings.TrimSpace(description) == "" && req.Logger != nil { - req.Logger.Infof("trackers: MTV preparation description empty") - } - - return trackers.DescriptionResult{ - Group: "mtv", - Description: description, - }, nil -} diff --git a/internal/trackers/impl/mtv/definition_test.go b/internal/trackers/impl/mtv/definition_test.go deleted file mode 100644 index 02e8c72b..00000000 --- a/internal/trackers/impl/mtv/definition_test.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package mtv - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestDefinitionBuildDescriptionUsesMTVGroup(t *testing.T) { - d := New() - result, err := d.BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "MTV", - Meta: api.PreparedMetadata{}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Group != "mtv" { - t.Fatalf("expected mtv group, got %q", result.Group) - } -} - -func TestDefinitionUploadMissingCookieFile(t *testing.T) { - d := New() - _, err := d.Upload(context.Background(), trackers.UploadRequest{ - Tracker: "MTV", - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(t.TempDir(), "ua.db")}}, - Logger: api.NopLogger{}, - }) - if err == nil { - t.Fatal("expected missing cookie file error") - } -} - -func TestDefinitionUploadSuccess(t *testing.T) { - tmp := t.TempDir() - dbPath := filepath.Join(tmp, "ua.db") - cookieDir := filepath.Join(tmp, "cookies") - if err := os.MkdirAll(cookieDir, 0o755); err != nil { - t.Fatalf("mkdir cookie dir: %v", err) - } - if err := os.WriteFile(filepath.Join(cookieDir, "MTV.json"), []byte(`{"session":"cookievalue"}`), 0o600); err != nil { - t.Fatalf("write cookie file: %v", err) - } - torrentPath := filepath.Join(tmp, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/index.php": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("...authkey=1234567890abcdef1234567890abcdef...")) - case "/upload.php": - if err := r.ParseMultipartForm(5 << 20); err != nil { - t.Errorf("parse multipart: %v", err) - return - } - if r.FormValue("auth") == "" { - t.Error("expected auth field") - return - } - http.Redirect(w, r, "/torrents.php?id=55", http.StatusFound) - case "/torrents.php": - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - d := New() - result, err := d.Upload(context.Background(), trackers.UploadRequest{ - Tracker: "MTV", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - VideoCodec: "HEVC", - ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", - ServiceLongName: "Netflix", - }, - TrackerConfig: config.TrackerConfig{URL: server.URL}, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected uploaded=1, got %d", result.Uploaded) - } -} - -func TestDefinitionUploadLoginBootstrapSuccess(t *testing.T) { - tmp := t.TempDir() - dbPath := filepath.Join(tmp, "ua.db") - torrentPath := filepath.Join(tmp, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - if r.Method == http.MethodGet { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(``)) - return - } - if err := r.ParseForm(); err != nil { - t.Errorf("parse login form: %v", err) - return - } - if r.FormValue("username") != "user" || r.FormValue("password") != "pass" { - t.Error("unexpected login credentials") - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "cookievalue", Path: "/"}) - http.Redirect(w, r, "/index.php", http.StatusFound) - case "/index.php": - if _, err := r.Cookie("session"); err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("...authkey=abcdefabcdefabcdefabcdefabcdefab...")) - case "/upload.php": - if err := r.ParseMultipartForm(5 << 20); err != nil { - t.Errorf("parse multipart: %v", err) - return - } - if r.FormValue("auth") == "" { - t.Error("expected auth field") - return - } - http.Redirect(w, r, "/torrents.php?id=99", http.StatusFound) - case "/torrents.php": - if _, err := url.ParseRequestURI(r.RequestURI); err != nil { - t.Errorf("invalid request uri: %v", err) - return - } - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - d := New() - result, err := d.Upload(context.Background(), trackers.UploadRequest{ - Tracker: "MTV", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - VideoCodec: "HEVC", - ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", - ServiceLongName: "Netflix", - }, - TrackerConfig: config.TrackerConfig{URL: server.URL, Username: "user", Password: "pass"}, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected uploaded=1, got %d", result.Uploaded) - } - if _, err := os.Stat(filepath.Join(tmp, "cookies", "MTV.json")); !os.IsNotExist(err) { - if err == nil { - t.Fatalf("expected no legacy MTV cookie file after login bootstrap; file exists") - } - t.Fatalf("expected no legacy MTV cookie file after login bootstrap; unexpected stat error: %v", err) - } -} diff --git a/internal/trackers/impl/mtv/upload.go b/internal/trackers/impl/mtv/upload.go deleted file mode 100644 index 70b5ae0e..00000000 --- a/internal/trackers/impl/mtv/upload.go +++ /dev/null @@ -1,847 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package mtv - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. - "encoding/base32" - "encoding/binary" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - cookiepkg "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/paths" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/services/db" - descriptionmtv "github.com/autobrr/upbrr/internal/services/description/mtv" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - mtvBaseURL = "https://www.morethantv.me" - mtvUploadPath = "/upload.php" - mtvIndexPath = "/index.php" - mtvUserAgentWeb = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" -) - -var mtvTokenPattern = regexp.MustCompile(`name="token"\s+value="([^"]{16,128})"`) - -// ErrSubmitted2FARejected marks an MTV failure after a submitted manual 2FA code -// reached the tracker and was rejected. -var ErrSubmitted2FARejected = errors.New("trackers: MTV submitted 2FA rejected") - -var errMTVAuthKeyNotFound = errors.New("trackers: MTV auth key not found") - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - select { - case <-ctx.Done(): - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - baseURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if baseURL == "" { - baseURL = mtvBaseURL - } - uploadURL := baseURL + mtvUploadPath - - cookies, err := loadMTVCookies(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - cookies = nil - } - auth, client, err := resolveAuthKey(ctx, baseURL, cookies) - if err != nil { - if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { - if cookies == nil { - return api.UploadSummary{}, errors.New("trackers: MTV cookie invalid/missing and username/password not configured") - } - return api.UploadSummary{}, err - } - var effectiveBaseURL string - auth, client, cookies, effectiveBaseURL, err = loginAndResolveAuthKey(ctx, req.TrackerConfig, baseURL, api.TrackerAuthLoginRequest{}) - if err != nil { - return api.UploadSummary{}, err - } - if strings.TrimSpace(effectiveBaseURL) != "" && !strings.EqualFold(effectiveBaseURL, baseURL) { - baseURL = effectiveBaseURL - uploadURL = baseURL + mtvUploadPath - } - if persistErr := saveMTVCookies(ctx, req.AppConfig.MainSettings.DBPath, cookies); persistErr != nil && req.Logger != nil { - req.Logger.Warnf("trackers: MTV failed to persist cookies: %v", persistErr) - } - } - - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - } - descText := strings.TrimSpace(assets.Description) - if !assets.Final { - descText, err = descriptionmtv.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.Screenshots) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.UploadSummary{}, err - } - - fields := buildUploadFields(req, auth, descText) - body, contentType, err := buildMultipartPayload(fields, torrentPath) - if err != nil { - return api.UploadSummary{}, err - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: MTV request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", mtvUserAgentWeb) - - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: MTV upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - - if resp.StatusCode == http.StatusBadRequest { - preview := string(bodyPreview) - if strings.Contains(preview, "Request Header") || strings.Contains(preview, "Cookie Too Large") || strings.Contains(preview, "Header Too Large") { - return api.UploadSummary{}, errors.New("trackers: MTV data error (request header/cookie too large)") - } - } - - if strings.Contains(finalURL, "torrents.php") { - return api.UploadSummary{Uploaded: 1}, nil - } - - return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("MTV", resp.StatusCode, finalURL, bodyPreview) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - select { - case <-ctx.Done(): - return api.TrackerDryRunEntry{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - var err error - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - } - descText := strings.TrimSpace(assets.Description) - if !assets.Final { - descText, err = descriptionmtv.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.Screenshots) - if err != nil { - return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: %w", err) - } - } - - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - - baseURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if baseURL == "" { - baseURL = mtvBaseURL - } - uploadURL := baseURL + mtvUploadPath - - const dryRunAuthPlaceholder = "dry-run-auth" - fields := buildUploadFields(req, dryRunAuthPlaceholder, descText) - - return api.TrackerDryRunEntry{ - Tracker: "MTV", - Status: "ready", - Message: "dry-run payload generated (auth placeholder used)", - ReleaseName: resolveUploadName(req.Meta), - DescriptionGroup: "mtv", - Description: descText, - Endpoint: uploadURL, - Payload: fields, - Files: []api.TrackerDryRunFile{{ - Field: "file_input", - Path: torrentPath, - Present: strings.TrimSpace(torrentPath) != "", - }}, - }, nil -} - -func resolveAuthKey(ctx context.Context, baseURL string, cookies map[string]string) (string, *http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV create auth cookie jar: %w", err) - } - parsedBase, err := url.Parse(baseURL) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV parse base URL: %w", err) - } - jarCookies := make([]*http.Cookie, 0, len(cookies)) - for name, value := range cookies { - if strings.TrimSpace(name) == "" || strings.TrimSpace(value) == "" { - continue - } - // #nosec G124 -- Outbound tracker jar cookie mirrors configured MTV session values. - jarCookies = append(jarCookies, &http.Cookie{Name: name, Value: value, Path: "/", Domain: parsedBase.Hostname()}) - } - jar.SetCookies(parsedBase, jarCookies) - - client := &http.Client{Timeout: 20 * time.Second, Jar: jar} - indexURL := strings.TrimRight(baseURL, "/") + mtvIndexPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV build auth request: %w", err) - } - req.Header.Set("User-Agent", mtvUserAgentWeb) - - resp, err := client.Do(req) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV auth request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", nil, fmt.Errorf("trackers: MTV auth key lookup failed %s: auth status %d", mtvResponseTrace(resp), resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV read auth response: %w", err) - } - auth := extractMTVAuthKey(string(body)) - if auth == "" { - return "", nil, fmt.Errorf("%w: %s", errMTVAuthKeyNotFound, mtvResponseTrace(resp)) - } - return auth, client, nil -} - -func loadMTVCookies(ctx context.Context, dbPath string) (map[string]string, error) { - return wrapTrackerResult(cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "MTV")) -} - -func saveMTVCookies(ctx context.Context, dbPath string, values map[string]string) error { - return wrapTrackerError(cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "MTV", values)) -} - -// ResolveSessionForTrackerAuth validates MTV stored cookies or logs in with -// configured credentials. After a successful login, cookie persistence failures -// are returned distinctly from remote authentication failures. -func ResolveSessionForTrackerAuth(ctx context.Context, cfg config.TrackerConfig, dbPath string) error { - return ResolveSessionForTrackerAuthLogin(ctx, cfg, dbPath, api.TrackerAuthLoginRequest{}) -} - -// ResolveSessionForTrackerAuthLogin validates MTV stored cookies or logs in -// with configured credentials. When MTV requires 2FA, login.Code is used before -// falling back to the configured OTP URI; if neither yields a code, the error -// is returned before stored cookies are replaced. A rejected submitted code can -// return [ErrSubmitted2FARejected] with the auth-key lookup failure before -// refreshed cookies are persisted. Response read errors are returned directly -// and are not classified as submitted-code rejections. -func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { - baseURL := strings.TrimRight(strings.TrimSpace(cfg.URL), "/") - if baseURL == "" { - baseURL = mtvBaseURL - } - cookies, err := loadMTVCookies(ctx, dbPath) - if err == nil && len(cookies) > 0 { - if _, _, err := resolveAuthKey(ctx, baseURL, cookies); err == nil { - return nil - } else if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return err - } - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return errors.New("trackers: MTV cookie invalid/missing and username/password not configured") - } - _, _, values, _, err := loginAndResolveAuthKey(ctx, cfg, baseURL, login) - if err != nil { - return err - } - if len(values) == 0 { - return errors.New("trackers: MTV login returned no usable cookies") - } - if err := saveMTVCookies(ctx, dbPath, values); err != nil { - return fmt.Errorf("trackers: MTV persist cookies after successful login: %w", err) - } - return nil -} - -// loginAndResolveAuthKey performs MTV login, optional TOTP submission, and auth -// key discovery. MTV can return authkey in the final login response, so that -// body is checked before falling back to the index page. Missing form tokens -// and auth-key parser failures remain ordinary errors unless an auth-key miss -// follows a submitted manual code. Authenticated cookies and the effective base -// URL are returned only after auth-key discovery so upload can reuse any -// canonical host reached during login redirects. -func loginAndResolveAuthKey(ctx context.Context, cfg config.TrackerConfig, baseURL string, login api.TrackerAuthLoginRequest) (string, *http.Client, map[string]string, string, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV create login cookie jar: %w", err) - } - client := &http.Client{Timeout: 25 * time.Second, Jar: jar} - - loginURL := strings.TrimRight(baseURL, "/") + "/login" - loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV build login page request: %w", err) - } - loginReq.Header.Set("User-Agent", mtvUserAgentWeb) - loginResp, err := client.Do(loginReq) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV login page request: %w", err) - } - loginBody, err := io.ReadAll(loginResp.Body) - _ = loginResp.Body.Close() - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV read login page response: %w", err) - } - effectiveBaseURL := mtvEffectiveBaseURL(baseURL, loginResp) - effectiveLoginURL := strings.TrimRight(effectiveBaseURL, "/") + "/login" - match := mtvTokenPattern.FindStringSubmatch(string(loginBody)) - if len(match) < 2 { - return "", nil, nil, "", errors.New("trackers: MTV login token not found") - } - token := strings.TrimSpace(match[1]) - - form := url.Values{} - form.Set("username", strings.TrimSpace(cfg.Username)) - form.Set("password", strings.TrimSpace(cfg.Password)) - form.Set("keeploggedin", "1") - form.Set("cinfo", "1920|1080|24|0") - form.Set("submit", "login") - form.Set("iplocked", "1") - form.Set("token", token) - - postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, effectiveLoginURL, strings.NewReader(form.Encode())) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV build login request: %w", err) - } - postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - postReq.Header.Set("User-Agent", mtvUserAgentWeb) - postResp, err := client.Do(postReq) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV login request: %w", err) - } - body, err := io.ReadAll(postResp.Body) - _ = postResp.Body.Close() - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV read login response: %w", err) - } - finalAuthBody := string(body) - finalAuthTrace := mtvResponseTrace(postResp) - - submittedManualCode := false - if postResp.Request != nil && postResp.Request.URL != nil && strings.Contains(postResp.Request.URL.Path, "/twofactor/login") { - twoFactorTokenMatch := mtvTokenPattern.FindStringSubmatch(string(body)) - if len(twoFactorTokenMatch) < 2 { - return "", nil, nil, "", errors.New("trackers: MTV 2FA token not found") - } - code, err := resolveMTV2FACode(cfg, login) - if err != nil { - return "", nil, nil, "", err - } - twoFactorForm := url.Values{} - twoFactorForm.Set("token", twoFactorTokenMatch[1]) - twoFactorForm.Set("code", code) - twoFactorForm.Set("submit", "login") - twoReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(effectiveBaseURL, "/")+"/twofactor/login", strings.NewReader(twoFactorForm.Encode())) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV build 2FA login request: %w", err) - } - twoReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - twoReq.Header.Set("User-Agent", mtvUserAgentWeb) - twoResp, err := client.Do(twoReq) - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV 2FA login request: %w", err) - } - twoFactorBody, err := io.ReadAll(twoResp.Body) - _ = twoResp.Body.Close() - if err != nil { - return "", nil, nil, "", fmt.Errorf("trackers: MTV read 2FA login response: %w", err) - } - finalAuthBody = string(twoFactorBody) - finalAuthTrace = mtvResponseTrace(twoResp) - submittedManualCode = strings.TrimSpace(login.Code) != "" - } - - if auth := extractMTVAuthKey(finalAuthBody); auth != "" { - cookieMap := cookiesFromJar(effectiveBaseURL, client.Jar) - return auth, client, cookieMap, effectiveBaseURL, nil - } - auth, authedClient, err := resolveAuthKeyFromClient(ctx, effectiveBaseURL, client) - if err != nil { - if submittedManualCode && errors.Is(err, errMTVAuthKeyNotFound) { - return "", nil, nil, "", fmt.Errorf("trackers: MTV auth key not found after submitted 2FA final_%s: %w: %w", finalAuthTrace, err, ErrSubmitted2FARejected) - } - if errors.Is(err, errMTVAuthKeyNotFound) { - return "", nil, nil, "", fmt.Errorf("trackers: MTV auth key not found after login final_%s: %w", finalAuthTrace, err) - } - return "", nil, nil, "", fmt.Errorf("trackers: MTV auth key lookup after login final_%s: %w", finalAuthTrace, err) - } - cookieMap := cookiesFromJar(effectiveBaseURL, authedClient.Jar) - return auth, authedClient, cookieMap, effectiveBaseURL, nil -} - -func mtvEffectiveBaseURL(fallback string, resp *http.Response) string { - if resp == nil || resp.Request == nil || resp.Request.URL == nil { - return strings.TrimRight(fallback, "/") - } - u := resp.Request.URL - if strings.TrimSpace(u.Scheme) == "" || strings.TrimSpace(u.Host) == "" { - return strings.TrimRight(fallback, "/") - } - return u.Scheme + "://" + u.Host -} - -func mtvResponseTrace(resp *http.Response) string { - if resp == nil { - return "path=unknown status=0" - } - path := "unknown" - if resp.Request != nil && resp.Request.URL != nil { - path = resp.Request.URL.EscapedPath() - if strings.TrimSpace(path) == "" { - path = "/" - } - } - return fmt.Sprintf("path=%s status=%d", path, resp.StatusCode) -} - -func extractMTVAuthKey(body string) string { - const marker = "authkey=" - idx := strings.LastIndex(body, marker) - if idx < 0 { - return "" - } - raw := strings.TrimSpace(body[idx+len(marker):]) - if len(raw) < 32 { - return "" - } - return raw[:32] -} - -// resolveMTV2FACode prefers a submitted manual code so users can continue a -// browser-visible challenge when no reusable OTP URI is configured. -func resolveMTV2FACode(cfg config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { - if code := strings.TrimSpace(login.Code); code != "" { - return code, nil - } - code, err := totpFromOTPURI(strings.TrimSpace(cfg.OTPURI), time.Now()) - if err != nil { - return "", fmt.Errorf("trackers: MTV 2FA required but otp_uri invalid: %w", err) - } - return code, nil -} - -func resolveAuthKeyFromClient(ctx context.Context, baseURL string, client *http.Client) (string, *http.Client, error) { - indexURL := strings.TrimRight(baseURL, "/") + mtvIndexPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV build auth request: %w", err) - } - req.Header.Set("User-Agent", mtvUserAgentWeb) - resp, err := client.Do(req) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV auth request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", nil, fmt.Errorf("trackers: MTV auth key lookup failed %s: auth status %d", mtvResponseTrace(resp), resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", nil, fmt.Errorf("trackers: MTV read auth response: %w", err) - } - auth := extractMTVAuthKey(string(body)) - if auth == "" { - return "", nil, fmt.Errorf("%w: %s", errMTVAuthKeyNotFound, mtvResponseTrace(resp)) - } - return auth, client, nil -} - -func cookiesFromJar(baseURL string, jar http.CookieJar) map[string]string { - if jar == nil { - return nil - } - parsed, err := url.Parse(baseURL) - if err != nil { - return nil - } - out := make(map[string]string) - for _, cookie := range jar.Cookies(parsed) { - if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { - continue - } - out[strings.TrimSpace(cookie.Name)] = cookie.Value - } - return out -} - -func totpFromOTPURI(otpURI string, now time.Time) (string, error) { - trimmed := strings.TrimSpace(otpURI) - if trimmed == "" { - return "", errors.New("empty otp_uri") - } - parsed, err := url.Parse(trimmed) - if err != nil { - return "", fmt.Errorf("trackers: MTV parse otp_uri: %w", err) - } - query := parsed.Query() - secret := strings.TrimSpace(query.Get("secret")) - if secret == "" { - return "", errors.New("missing secret query parameter") - } - interval := 30 - if period := strings.TrimSpace(query.Get("period")); period != "" { - if value, err := strconv.Atoi(period); err == nil && value > 0 { - interval = value - } - } - counterTime := now.Unix() / int64(interval) - if counterTime < 0 { - return "", errors.New("totp counter before unix epoch") - } - counter := uint64(counterTime) - - decoder := base32.StdEncoding.WithPadding(base32.NoPadding) - secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) - if err != nil { - return "", fmt.Errorf("trackers: MTV decode otp secret: %w", err) - } - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, counter) - mac := hmac.New(sha1.New, secretBytes) - _, _ = mac.Write(buf) - hash := mac.Sum(nil) - offset := hash[len(hash)-1] & 0x0f - code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) - return fmt.Sprintf("%06d", code%1000000), nil -} - -func buildUploadFields(req trackers.UploadRequest, auth string, description string) map[string]string { - meta := req.Meta - anon := "1" - if !req.TrackerConfig.Anon { - anon = "0" - } - return map[string]string{ - "image": "", - "title": resolveUploadName(meta), - "category": resolveCategoryID(meta), - "Resolution": resolveResolutionID(meta), - "source": resolveSourceID(meta), - "origin": resolveOriginID(meta), - "taglist": resolveTags(meta), - "desc": strings.TrimSpace(description), - "groupDesc": resolveGroupDescription(meta), - "ignoredupes": "1", - "genre_tags": "---", - "autocomplete_toggle": "on", - "fontfont": "-1", - "fontsize": "-1", - "auth": auth, - "anonymous": anon, - "submit": "true", - } -} - -func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - for key, value := range fields { - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: MTV write multipart field %q: %w", key, err) - } - } - file, err := os.Open(torrentPath) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: MTV open torrent file: %w", err) - } - defer file.Close() - part, err := writer.CreateFormFile("file_input", "[MTV].torrent") - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: MTV create torrent form file: %w", err) - } - if _, err := io.Copy(part, file); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: MTV copy torrent file: %w", err) - } - if err := writer.Close(); err != nil { - return nil, "", fmt.Errorf("trackers: MTV close multipart writer: %w", err) - } - return body.Bytes(), writer.FormDataContentType(), nil -} - -func resolveTorrentPath(meta api.PreparedMetadata, dbPath string) (string, error) { - candidates := []string{ - strings.TrimSpace(meta.TorrentPath), - strings.TrimSpace(meta.ClientTorrentPath), - strings.TrimSpace(meta.SourcePath), - } - for _, candidate := range candidates { - if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { - continue - } - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate, nil - } - } - if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err == nil { - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err == nil { - guessed := filepath.Join(tmpDir, base+".torrent") - if info, err := os.Stat(guessed); err == nil && !info.IsDir() { - return guessed, nil - } - } - } - } - return "", errors.New("trackers: MTV torrent file not found") -} - -func resolveUploadName(meta api.PreparedMetadata) string { - if name := strings.TrimSpace(meta.ReleaseName); name != "" { - return cleanName(name) - } - if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { - return cleanName(name) - } - if name := strings.TrimSpace(meta.Filename); name != "" { - return cleanName(name) - } - return cleanName(pathutil.Base(meta.SourcePath)) -} - -func cleanName(value string) string { - value = strings.Join(strings.Fields(value), " ") - value = strings.ReplaceAll(value, " ", ".") - value = strings.ReplaceAll(value, "..", ".") - return strings.TrimSpace(value) -} - -func resolveResolution(meta api.PreparedMetadata) string { - if value := strings.TrimSpace(meta.Release.Resolution); value != "" { - return value - } - if strings.TrimSpace(meta.UHD) != "" { - return "2160p" - } - return "" -} - -func resolveResolutionID(meta api.PreparedMetadata) string { - res := strings.ToLower(strings.TrimSpace(resolveResolution(meta))) - values := map[string]string{ - "8640p": "0", "4320p": "4000", "2160p": "2160", "1440p": "1440", "1080p": "1080", "1080i": "1080", "720p": "720", "576p": "0", "576i": "0", "480p": "480", "480i": "480", - } - if value, ok := values[res]; ok { - return value - } - return "10" -} - -func isSD(meta api.PreparedMetadata) bool { - res := strings.ToLower(resolveResolution(meta)) - return strings.Contains(res, "480") || strings.Contains(res, "576") -} - -func resolveCategory(meta api.PreparedMetadata) string { - category := strings.ToUpper(strings.TrimSpace(meta.ExternalIDs.Category)) - if category == "" { - category = strings.ToUpper(strings.TrimSpace(meta.MediaInfoCategory)) - } - return category -} - -func resolveCategoryID(meta api.PreparedMetadata) string { - category := resolveCategory(meta) - if category == "MOVIE" { - if isSD(meta) { - return "2" - } - return "1" - } - if category == "TV" { - if meta.TVPack { - if isSD(meta) { - return "6" - } - return "5" - } - if isSD(meta) { - return "4" - } - return "3" - } - return "0" -} - -func resolveType(meta api.PreparedMetadata) string { - value := strings.ToUpper(strings.TrimSpace(meta.Type)) - if value == "" { - value = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) - } - return strings.ReplaceAll(strings.ReplaceAll(value, "-", ""), " ", "") -} - -func resolveSourceID(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - return "1" - } - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || resolveType(meta) == "REMUX" { - return "7" - } - mapping := map[string]string{ - "DISC": "1", "WEBDL": "9", "WEBRIP": "10", "HDTV": "1", "SDTV": "2", "TVRIP": "3", "DVD": "4", "DVDRIP": "5", "BDRIP": "8", "VHS": "6", "MIXED": "11", "ENCODE": "7", - } - if value, ok := mapping[resolveType(meta)]; ok { - return value - } - return "0" -} - -func resolveOriginID(meta api.PreparedMetadata) string { - if meta.PersonalRelease { - return "4" - } - if meta.Scene { - return "2" - } - return "3" -} - -func resolveTags(meta api.PreparedMetadata) string { - tags := make([]string, 0, 12) - resolution := strings.ToLower(strings.TrimSpace(resolveResolution(meta))) - if resolution != "" { - tags = append(tags, resolution) - } - switch { - case isSD(meta): - tags = append(tags, "sd") - case resolution == "2160p" || resolution == "4320p": - tags = append(tags, "uhd") - default: - tags = append(tags, "hd") - } - if service := strings.TrimSpace(meta.ServiceLongName); service != "" { - svc := strings.ToLower(strings.ReplaceAll(service, " ", ".")) - svc = strings.ReplaceAll(svc, "+", "plus") - tags = append(tags, svc+".source") - } - switch category := resolveCategory(meta); category { - case "TV": - switch { - case meta.TVPack && isSD(meta): - tags = append(tags, "sd.season") - case meta.TVPack: - tags = append(tags, "hd.season") - case isSD(meta): - tags = append(tags, "sd.episode") - default: - tags = append(tags, "hd.episode") - } - case "MOVIE": - if isSD(meta) { - tags = append(tags, "sd.movie") - } else { - tags = append(tags, "hd.movie") - } - } - audio := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(meta.Audio, "+", "p"), "-", "."), " ", ".")) - for _, token := range []string{"dd", "ddp", "aac", "truehd", "mp3", "mp2", "dts", "dts.hd", "dts.x"} { - if strings.Contains(audio, token) { - tags = append(tags, token+".audio") - break - } - } - if strings.Contains(strings.ToLower(meta.Audio), "atmos") { - tags = append(tags, "atmos.audio") - } - codec := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(meta.VideoCodec), "avc", "h264"), "hevc", "h265") - codec = strings.ReplaceAll(codec, "-", "") - if strings.TrimSpace(codec) != "" { - tags = append(tags, strings.TrimSpace(codec)) - } - if tag := strings.TrimSpace(meta.Tag); tag != "" { - tags = append(tags, strings.TrimPrefix(strings.ReplaceAll(tag, " ", "."), "-")+".release") - } else { - tags = append(tags, "NOGRP.release") - } - if meta.Scene { - tags = append(tags, "scene.group.release") - } else { - tags = append(tags, "p2p.group.release") - } - return strings.Join(tags, " ") -} - -func resolveGroupDescription(meta api.PreparedMetadata) string { - parts := make([]string, 0, 5) - if meta.ExternalMetadata.IMDB != nil { - if imdbURL := strings.TrimSpace(meta.ExternalMetadata.IMDB.IMDbURL); imdbURL != "" { - parts = append(parts, imdbURL) - } - } - if meta.ExternalIDs.TMDBID != 0 { - category := strings.ToLower(strings.TrimSpace(resolveCategory(meta))) - if category == "" { - category = "movie" - } - parts = append(parts, "https://www.themoviedb.org/"+category+"/"+strconv.Itoa(meta.ExternalIDs.TMDBID)) - } - if strings.EqualFold(resolveCategory(meta), "TV") && meta.ExternalIDs.TVDBID != 0 { - parts = append(parts, "https://www.thetvdb.com/?id="+strconv.Itoa(meta.ExternalIDs.TVDBID)) - } - if meta.ExternalIDs.TVmazeID != 0 { - parts = append(parts, "https://www.tvmaze.com/shows/"+strconv.Itoa(meta.ExternalIDs.TVmazeID)) - } - if meta.ExternalIDs.MALID != 0 { - parts = append(parts, "https://myanimelist.net/anime/"+strconv.Itoa(meta.ExternalIDs.MALID)) - } - return strings.Join(parts, "\n") -} diff --git a/internal/trackers/impl/mtv/upload_test.go b/internal/trackers/impl/mtv/upload_test.go deleted file mode 100644 index ec67f087..00000000 --- a/internal/trackers/impl/mtv/upload_test.go +++ /dev/null @@ -1,734 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package mtv - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/authmaterial" - "github.com/autobrr/upbrr/internal/config" - cookiepkg "github.com/autobrr/upbrr/internal/cookies" - servicedb "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestResolveGroupDescriptionSkipsTVDBForMovie(t *testing.T) { - got := resolveGroupDescription(api.PreparedMetadata{ - MediaInfoCategory: "TV", - ExternalIDs: api.ExternalIDs{Category: "MOVIE", TVDBID: 456}, - }) - if strings.Contains(got, "thetvdb.com") { - t.Fatalf("did not expect tvdb link for movie description, got %q", got) - } -} - -func TestResolveGroupDescriptionIncludesTVDBForTV(t *testing.T) { - got := resolveGroupDescription(api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "TV", TVDBID: 456}, - }) - if !strings.Contains(got, "thetvdb.com/?id=456") { - t.Fatalf("expected tvdb link for TV description, got %q", got) - } -} - -func TestExtractMTVAuthKeyMatchesUploadAssistantShape(t *testing.T) { - t.Parallel() - - auth := "abcdefghijklmnopqrstuvwxyzABCDEF" - tests := map[string]string{ - "plain": "authkey=" + auth, - "query tail": "https://example.test/upload?authkey=" + auth + "&x=1", - "last marker": "authkey=oldoldoldoldoldoldoldoldoldoldoldold authkey=" + auth + `">`, - } - for name, body := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - if got := extractMTVAuthKey(body); got != auth { - t.Fatal("expected extracted auth key") - } - }) - } -} - -func TestResolveSessionForTrackerAuthPreservesCookiesOnTransientAuthFetch(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "abc"}); err != nil { - t.Fatalf("saveMTVCookies: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - baseURL := server.URL - server.Close() - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: baseURL, - Username: "user", - Password: "pass", - }, dbPath) - if err == nil { - t.Fatal("expected transient auth fetch error") - } - got, loadErr := loadMTVCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadMTVCookies after transient error: %v", loadErr) - } - if got["session"] != "abc" { - t.Fatalf("expected transient failure to preserve cookies, got %#v", got) - } -} - -func TestResolveSessionForTrackerAuthReportsPostLoginCookiePersistenceFailure(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "abc", Path: "/"}) - _, _ = w.Write([]byte(``)) - case "/index.php": - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuth(context.Background(), config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, filepath.Join(t.TempDir(), "upbrr.db")) - if !errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { - t.Fatalf("expected auth helper unavailable error, got %v", err) - } - if !strings.Contains(err.Error(), "persist cookies after successful login") { - t.Fatalf("expected distinct persistence failure, got %v", err) - } -} - -func TestResolveSessionForTrackerAuthSavesCookiesWhenLoginResponseContainsAuthKey(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - indexRequests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "fresh", Path: "/"}) - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - case "/index.php": - indexRequests++ - _, _ = w.Write([]byte(`logged out`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath) - if err != nil { - t.Fatalf("ResolveSessionForTrackerAuth: %v", err) - } - if indexRequests != 0 { - t.Fatalf("expected login response authkey to avoid index lookup, got %d", indexRequests) - } - got, err := loadMTVCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadMTVCookies: %v", err) - } - if got["session"] != "fresh" { - t.Fatalf("expected saved login cookies, got %#v", got) - } -} - -func TestLoginAndResolveAuthKeyReturnsLoginPageReadError(t *testing.T) { - t.Parallel() - - handlerErrs := make(chan string, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/login" && r.Method == http.MethodGet: - if err := writeMalformedChunkedResponse(w); err != nil { - recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) - } - default: - recordHandlerError(handlerErrs, "unexpected request %s %s", r.Method, r.URL.Path) - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - _, _, _, _, err := loginAndResolveAuthKey(context.Background(), config.TrackerConfig{ - Username: "user", - Password: "pass", - }, server.URL, api.TrackerAuthLoginRequest{}) - assertNoHandlerError(t, handlerErrs) - if err == nil { - t.Fatal("expected login page read error") - } - if !strings.Contains(err.Error(), "read login page response") { - t.Fatalf("expected login page read-error context, got %v", err) - } -} - -func TestLoginAndResolveAuthKeyReturnsLoginPostReadError(t *testing.T) { - t.Parallel() - - handlerErrs := make(chan string, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/login" && r.Method == http.MethodGet: - _, _ = w.Write([]byte(``)) - case r.URL.Path == "/login" && r.Method == http.MethodPost: - if err := writeMalformedChunkedResponse(w); err != nil { - recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) - } - default: - recordHandlerError(handlerErrs, "unexpected request %s %s", r.Method, r.URL.Path) - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - _, _, _, _, err := loginAndResolveAuthKey(context.Background(), config.TrackerConfig{ - Username: "user", - Password: "pass", - }, server.URL, api.TrackerAuthLoginRequest{}) - assertNoHandlerError(t, handlerErrs) - if err == nil { - t.Fatal("expected login response read error") - } - if !strings.Contains(err.Error(), "read login response") { - t.Fatalf("expected login response read-error context, got %v", err) - } -} - -func TestResolveSessionForTrackerAuthPostsLoginToRedirectedHost(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - var canonicalURL string - postedCanonicalLogin := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/login" && r.Method == http.MethodGet && strings.HasPrefix(r.Host, "localhost:"): - http.Redirect(w, r, canonicalURL+"/login", http.StatusFound) - case r.URL.Path == "/login" && r.Method == http.MethodGet: - _, _ = w.Write([]byte(``)) - case r.URL.Path == "/login" && r.Method == http.MethodPost && strings.HasPrefix(r.Host, "localhost:"): - t.Error("login POST used original host") - return - case r.URL.Path == "/login" && r.Method == http.MethodPost: - postedCanonicalLogin = true - http.SetCookie(w, &http.Cookie{Name: "session", Value: "fresh", Path: "/"}) - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - canonicalURL = server.URL - sourceURL := strings.Replace(server.URL, "127.0.0.1", "localhost", 1) - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: sourceURL, - Username: "user", - Password: "pass", - }, dbPath) - if err != nil { - t.Fatalf("ResolveSessionForTrackerAuth: %v", err) - } - if !postedCanonicalLogin { - t.Fatal("expected login POST on redirected host") - } - got, err := loadMTVCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadMTVCookies: %v", err) - } - if got["session"] != "fresh" { - t.Fatalf("expected saved redirected-login cookies, got %#v", got) - } -} - -func TestUploadPostsToRedirectedLoginHost(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "upbrr.db") - torrentPath := filepath.Join(t.TempDir(), "release.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - var canonicalURL string - postedCanonicalUpload := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/login" && r.Method == http.MethodGet && strings.HasPrefix(r.Host, "localhost:"): - http.Redirect(w, r, canonicalURL+"/login", http.StatusFound) - case r.URL.Path == "/login" && r.Method == http.MethodGet: - _, _ = w.Write([]byte(``)) - case r.URL.Path == "/login" && r.Method == http.MethodPost: - http.SetCookie(w, &http.Cookie{Name: "session", Value: "fresh", Path: "/"}) - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - case r.URL.Path == mtvUploadPath && r.Method == http.MethodPost && strings.HasPrefix(r.Host, "localhost:"): - t.Error("upload POST used original host") - return - case r.URL.Path == mtvUploadPath && r.Method == http.MethodPost: - postedCanonicalUpload = true - if _, err := r.Cookie("session"); err != nil { - t.Errorf("expected session cookie on canonical upload: %v", err) - return - } - http.Redirect(w, r, canonicalURL+"/torrents.php?id=1", http.StatusFound) - case r.URL.Path == "/torrents.php": - _, _ = w.Write([]byte("ok")) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - canonicalURL = server.URL - sourceURL := strings.Replace(server.URL, "127.0.0.1", "localhost", 1) - - summary, err := upload(ctx, trackers.UploadRequest{ - Tracker: "MTV", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - ReleaseName: "Release", - }, - TrackerConfig: config.TrackerConfig{ - URL: sourceURL, - Username: "user", - Password: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Assets: &trackers.DescriptionAssets{Final: true, Description: "desc"}, - }) - if err != nil { - t.Fatalf("upload: %v", err) - } - if summary.Uploaded != 1 { - t.Fatalf("expected upload success, got %#v", summary) - } - if !postedCanonicalUpload { - t.Fatal("expected upload POST on redirected host") - } -} - -func TestResolveSessionForTrackerAuthRejectsEmptyLoginCookiesWithoutReplacingStoredCookies(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { - t.Fatalf("saveMTVCookies: %v", err) - } - indexRequests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - _, _ = w.Write([]byte(`ok`)) - case "/index.php": - indexRequests++ - if indexRequests == 1 { - _, _ = w.Write([]byte(`logged out`)) - return - } - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath) - if err == nil || !strings.Contains(err.Error(), "no usable cookies") { - t.Fatalf("expected empty login cookie error, got %v", err) - } - got, loadErr := loadMTVCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadMTVCookies after empty login cookies: %v", loadErr) - } - if got["session"] != "existing" { - t.Fatalf("expected empty login cookies to preserve stored cookies, got %#v", got) - } -} - -func TestResolveSessionForTrackerAuthDoesNotPersistCookiesBeforeAuthKeyValidation(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "unverified", Path: "/"}) - _, _ = w.Write([]byte(`logged in without auth key`)) - case "/index.php": - _, _ = w.Write([]byte(`temporary account notice`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath) - if err == nil || !strings.Contains(err.Error(), "auth key not found") { - t.Fatalf("expected auth-key validation failure, got %v", err) - } - if values, loadErr := loadMTVCookies(ctx, dbPath); !errors.Is(loadErr, cookiepkg.ErrTrackerCookiesNotFound) { - t.Fatalf("unverified login cookies persisted or unexpected load error values=%#v err=%v", values, loadErr) - } -} - -func TestResolveSessionForTrackerAuthLoginUsesManual2FACode(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - var gotCode string - indexRequests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/index.php": - indexRequests++ - _, _ = w.Write([]byte(`logged out`)) - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.Redirect(w, r, "/twofactor/login", http.StatusFound) - case "/twofactor/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - if err := r.ParseForm(); err != nil { - t.Errorf("ParseForm: %v", err) - return - } - gotCode = r.FormValue("code") - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{Code: "654321"}) - if err != nil { - t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) - } - if gotCode != "654321" { - t.Fatalf("expected manual 2FA code, got %q", gotCode) - } - if indexRequests != 0 { - t.Fatalf("expected 2FA response authkey to avoid index lookup, got %d", indexRequests) - } - got, err := loadMTVCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadMTVCookies: %v", err) - } - if got["session"] != "new" { - t.Fatalf("expected saved 2FA login cookies, got %#v", got) - } -} - -func TestResolveSessionForTrackerAuthLoginMarksSubmitted2FAAuthKeyMiss(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { - t.Fatalf("saveMTVCookies: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/index.php": - _, _ = w.Write([]byte(`logged out`)) - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.Redirect(w, r, "/twofactor/login", http.StatusFound) - case "/twofactor/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - _, _ = w.Write([]byte(`invalid code`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}) - if !errors.Is(err, ErrSubmitted2FARejected) { - t.Fatalf("expected submitted 2FA rejection marker, got %v", err) - } - if !strings.Contains(err.Error(), "final_path=/twofactor/login status=200") || !strings.Contains(err.Error(), "path=/index.php status=200") { - t.Fatalf("expected safe path/status diagnostics, got %v", err) - } - got, loadErr := loadMTVCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadMTVCookies: %v", loadErr) - } - if got["session"] != "existing" { - t.Fatalf("submitted 2FA rejection must preserve stored cookies, got %#v", got) - } -} - -func TestResolveSessionForTrackerAuthLoginReturns2FAReadError(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { - t.Fatalf("saveMTVCookies: %v", err) - } - handlerErrs := make(chan string, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/index.php": - _, _ = w.Write([]byte(`logged out`)) - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.Redirect(w, r, "/twofactor/login", http.StatusFound) - case "/twofactor/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - if err := writeMalformedChunkedResponse(w); err != nil { - recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) - } - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{Code: "123456"}) - assertNoHandlerError(t, handlerErrs) - if err == nil { - t.Fatal("expected 2FA response read error") - } - if errors.Is(err, ErrSubmitted2FARejected) { - t.Fatalf("read error must not be classified as submitted 2FA rejection: %v", err) - } - if !strings.Contains(err.Error(), "read 2FA login response") { - t.Fatalf("expected read-error context, got %v", err) - } - got, loadErr := loadMTVCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadMTVCookies: %v", loadErr) - } - if got["session"] != "existing" { - t.Fatalf("2FA read error must preserve stored cookies, got %#v", got) - } -} - -func TestResolveSessionForTrackerAuthLoginReportsSafeAuthKeyDiagnostics(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`login accepted but no upload token secret-response-body`)) - case "/index.php": - _, _ = w.Write([]byte(`logged in but no upload token other-secret-body`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) - if !errors.Is(err, errMTVAuthKeyNotFound) { - t.Fatalf("expected auth key miss, got %v", err) - } - if !strings.Contains(err.Error(), "final_path=/login status=200") || !strings.Contains(err.Error(), "path=/index.php status=200") { - t.Fatalf("expected path/status diagnostics, got %v", err) - } - for _, secret := range []string{"secret-response-body", "other-secret-body"} { - if strings.Contains(err.Error(), secret) { - t.Fatalf("diagnostic leaked response body %q: %v", secret, err) - } - } -} - -func TestResolveSessionForTrackerAuthLoginMissing2FACodePreservesCookies(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newMTVAuthDB(t) - if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { - t.Fatalf("saveMTVCookies: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/index.php": - _, _ = w.Write([]byte(`logged out`)) - case "/login": - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(``)) - return - } - http.Redirect(w, r, "/twofactor/login", http.StatusFound) - case "/twofactor/login": - _, _ = w.Write([]byte(``)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) - if err == nil || !strings.Contains(err.Error(), "2FA required") { - t.Fatalf("expected missing 2FA error, got %v", err) - } - got, loadErr := loadMTVCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadMTVCookies: %v", loadErr) - } - if got["session"] != "existing" { - t.Fatalf("missing 2FA code must preserve stored cookies, got %#v", got) - } -} - -func newMTVAuthDB(t *testing.T) string { - t.Helper() - - ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "upbrr.db") - if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { - t.Fatalf("BootstrapAuthFile: %v", err) - } - repo, err := servicedb.OpenWithLoggerContext(ctx, dbPath, api.NopLogger{}) - if err != nil { - t.Fatalf("OpenWithLoggerContext: %v", err) - } - if err := repo.MigrateContext(ctx); err != nil { - _ = repo.Close() - t.Fatalf("MigrateContext: %v", err) - } - _ = repo.Close() - return dbPath -} - -// recordHandlerError preserves handler failures for assertion from the test -// goroutine, where testing.T failures are safe to report. -func recordHandlerError(errs chan<- string, format string, args ...any) { - select { - case errs <- fmt.Sprintf(format, args...): - default: - } -} - -// assertNoHandlerError fails the test if the server handler recorded a setup or -// routing error while servicing the request under test. -func assertNoHandlerError(t *testing.T, errs <-chan string) { - t.Helper() - - select { - case err := <-errs: - t.Fatal(err) - default: - } -} - -// writeMalformedChunkedResponse sends an intentionally truncated chunked body so -// callers exercise response-body read errors after headers are received. -func writeMalformedChunkedResponse(w http.ResponseWriter) error { - hijacker, ok := w.(http.Hijacker) - if !ok { - return errors.New("response writer does not support hijacking") - } - conn, rw, err := hijacker.Hijack() - if err != nil { - return fmt.Errorf("hijack response: %w", err) - } - if _, err := rw.WriteString("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n20\r\npartial"); err != nil { - _ = conn.Close() - return fmt.Errorf("write response: %w", err) - } - if err := rw.Flush(); err != nil { - _ = conn.Close() - return fmt.Errorf("flush response: %w", err) - } - _ = conn.Close() - return nil -} diff --git a/internal/trackers/impl/mtv/wrap_errors.go b/internal/trackers/impl/mtv/wrap_errors.go deleted file mode 100644 index 867b93b3..00000000 --- a/internal/trackers/impl/mtv/wrap_errors.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package mtv - -import "fmt" - -func wrapTrackerError(err error) error { - if err != nil { - return fmt.Errorf("trackers: %w", err) - } - return nil -} - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/nbl/definition.go b/internal/trackers/impl/nbl/definition.go deleted file mode 100644 index 006f3f2c..00000000 --- a/internal/trackers/impl/nbl/definition.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package nbl - -import ( - "context" - "fmt" - "strings" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type Definition struct{} - -func New() *Definition { - return &Definition{} -} - -func (d *Definition) Name() string { - return "NBL" -} - -func (d *Definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (d *Definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (d *Definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - select { - case <-ctx.Done(): - return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - var err error - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - if req.Logger != nil { - req.Logger.Errorf( - "trackers: %s description assets resolution failed source=%s: %v", - strings.ToUpper(strings.TrimSpace(req.Tracker)), - strings.TrimSpace(req.Meta.SourcePath), - err, - ) - } - assets = trackers.DescriptionAssets{} - } - } - - return trackers.DescriptionResult{ - Group: "nbl", - Description: strings.TrimSpace(assets.Description), - }, nil -} diff --git a/internal/trackers/impl/nbl/definition_test.go b/internal/trackers/impl/nbl/definition_test.go deleted file mode 100644 index 45d8107a..00000000 --- a/internal/trackers/impl/nbl/definition_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package nbl - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestDefinitionBuildUploadDryRunBuildsPayload(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") - torrentPath := filepath.Join(tmp, "Show.torrent") - if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - entry, err := New().BuildUploadDryRun(context.Background(), trackers.UploadRequest{ - Tracker: "NBL", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Show.mkv"), - TorrentPath: torrentPath, - MediaInfoTextPath: mediaInfoPath, - ExternalIDs: api.ExternalIDs{TVmazeID: 987}, - TVPack: true, - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - AppConfig: config.Config{}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if entry.Payload["tvmazeid"] != "987" { - t.Fatalf("expected tvmazeid 987, got %q", entry.Payload["tvmazeid"]) - } - if entry.Payload["category"] != "3" { - t.Fatalf("expected pack category 3, got %q", entry.Payload["category"]) - } - if entry.Questionnaire != nil { - t.Fatal("expected no questionnaire") - } -} diff --git a/internal/trackers/impl/nbl/upload.go b/internal/trackers/impl/nbl/upload.go deleted file mode 100644 index 6bcc07ab..00000000 --- a/internal/trackers/impl/nbl/upload.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package nbl - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "regexp" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/httpclient" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const nblUploadURL = "https://nebulance.io/api.php" - -var nblTorrentIDPattern = regexp.MustCompile(`id=(\d+)`) - -type uploadState struct { - torrentPath string - fields map[string]string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - - body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) - if err != nil { - return api.UploadSummary{}, err - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, nblUploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: NBL request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - - resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: NBL upload request: %w", err) - } - defer resp.Body.Close() - - bodyBytes, bodyPreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 300, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: NBL read response: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return api.UploadSummary{}, commonhttp.UploadHTTPError("NBL", resp.StatusCode, bodyPreview) - } - - payload := map[string]any{} - if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, &payload); err != nil { - return api.UploadSummary{}, errors.New("trackers: NBL json decode error, the API is probably down") - } - } - - torrentURL, torrentID := extractUploadLinkAndID(payload) - - artifactPath := "" - if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "NBL") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, "NBL"); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "NBL", - TorrentID: torrentID, - DownloadURL: torrentURL, - TorrentURL: torrentURL, - TorrentPath: artifactPath, - }}, - }, nil -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - return api.TrackerDryRunEntry{ - Tracker: "NBL", - Status: "ready", - Message: "dry-run payload generated", - ReleaseName: resolveUploadName(req.Meta), - DescriptionGroup: "nbl", - Endpoint: nblUploadURL, - Payload: state.fields, - Files: []api.TrackerDryRunFile{{ - Field: "file_input", - Path: state.torrentPath, - Present: strings.TrimSpace(state.torrentPath) != "", - }}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - select { - case <-ctx.Done(): - return uploadState{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { - return uploadState{}, errors.New("trackers: NBL missing api_key") - } - - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - mediaInfo, err := resolveMediaInfo(req.Meta) - if err != nil { - return uploadState{}, err - } - - fields := map[string]string{ - "action": "upload", - "api_key": strings.TrimSpace(req.TrackerConfig.APIKey), - "tvmazeid": strconv.Itoa(req.Meta.ExternalIDs.TVmazeID), - "mediainfo": mediaInfo, - "category": strconv.Itoa(resolveCategoryID(req.Meta)), - "ignoredupes": "1", - } - - return uploadState{torrentPath: torrentPath, fields: fields}, nil -} - -func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - for key, value := range fields { - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: NBL write multipart field %q: %w", key, err) - } - } - file, err := os.Open(torrentPath) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: NBL open torrent file: %w", err) - } - defer file.Close() - part, err := writer.CreateFormFile("file_input", "torrent.torrent") - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: NBL create torrent form file: %w", err) - } - if _, err := io.Copy(part, file); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: NBL copy torrent file: %w", err) - } - if err := writer.Close(); err != nil { - return nil, "", fmt.Errorf("trackers: NBL close multipart writer: %w", err) - } - return body.Bytes(), writer.FormDataContentType(), nil -} - -func resolveMediaInfo(meta api.PreparedMetadata) (string, error) { - if strings.TrimSpace(meta.MediaInfoTextPath) == "" { - return "", errors.New("trackers: NBL missing mediainfo text") - } - payload, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) - if err != nil { - return "", fmt.Errorf("trackers: NBL read mediainfo: %w", err) - } - return string(payload), nil -} - -func resolveCategoryID(meta api.PreparedMetadata) int { - if meta.TVPack { - return 3 - } - return 1 -} - -func resolveUploadName(meta api.PreparedMetadata) string { - if name := strings.TrimSpace(meta.ReleaseName); name != "" { - return name - } - if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { - return name - } - if name := strings.TrimSpace(meta.Filename); name != "" { - return name - } - return pathutil.Base(meta.SourcePath) -} - -func nblString(value any) string { - if value == nil { - return "" - } - - switch typed := value.(type) { - case string: - return strings.TrimSpace(typed) - default: - return strings.TrimSpace(fmt.Sprint(value)) - } -} - -func extractUploadLinkAndID(payload map[string]any) (string, string) { - torrentURL := strings.TrimSpace(nblString(payload["link"])) - if torrentURL == "" { - if result, ok := payload["result"].(map[string]any); ok { - torrentURL = strings.TrimSpace(nblString(result["link"])) - } - } - - torrentID := "" - if matches := nblTorrentIDPattern.FindStringSubmatch(torrentURL); len(matches) > 1 { - torrentID = strings.TrimSpace(matches[1]) - } - - return torrentURL, torrentID -} diff --git a/internal/trackers/impl/nbl/upload_test.go b/internal/trackers/impl/nbl/upload_test.go deleted file mode 100644 index c608b783..00000000 --- a/internal/trackers/impl/nbl/upload_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package nbl - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestPrepareUploadStateUsesNumericIgnoreDupes(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "release.torrent") - mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") - if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - if err := os.WriteFile(mediaInfoPath, []byte("mediainfo"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - - state, err := prepareUploadState(context.Background(), trackers.UploadRequest{ - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - MediaInfoTextPath: mediaInfoPath, - }, - TrackerConfig: config.TrackerConfig{APIKey: "api-key"}, - }) - if err != nil { - t.Fatalf("prepare upload state: %v", err) - } - if got := state.fields["ignoredupes"]; got != "1" { - t.Fatalf("expected ignoredupes=1, got %q", got) - } -} - -func TestExtractUploadLinkAndIDFromRootLink(t *testing.T) { - t.Parallel() - - payload := map[string]any{ - "link": "https://nebulance.io/torrents.php?id=12345", - } - - link, id := extractUploadLinkAndID(payload) - if link != "https://nebulance.io/torrents.php?id=12345" { - t.Fatalf("expected upload link from root, got %q", link) - } - if id != "12345" { - t.Fatalf("expected torrent id 12345, got %q", id) - } -} - -func TestExtractUploadLinkAndIDFromResultLink(t *testing.T) { - t.Parallel() - - payload := map[string]any{ - "result": map[string]any{ - "link": "https://nebulance.io/torrents.php?id=67890", - }, - } - - link, id := extractUploadLinkAndID(payload) - if link != "https://nebulance.io/torrents.php?id=67890" { - t.Fatalf("expected upload link from nested result, got %q", link) - } - if id != "67890" { - t.Fatalf("expected torrent id 67890, got %q", id) - } -} - -func TestExtractUploadLinkAndIDEmptyWhenMissingLink(t *testing.T) { - t.Parallel() - - payload := map[string]any{"status": "ok"} - - link, id := extractUploadLinkAndID(payload) - if link != "" { - t.Fatalf("expected empty upload link when missing, got %q", link) - } - if id != "" { - t.Fatalf("expected empty torrent id when link missing, got %q", id) - } -} diff --git a/internal/trackers/impl/ptp/definition.go b/internal/trackers/impl/ptp/definition.go deleted file mode 100644 index 00187e38..00000000 --- a/internal/trackers/impl/ptp/definition.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ptp - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type Definition struct{} - -func New() *Definition { - return &Definition{} -} - -func (d *Definition) Name() string { - return "PTP" -} - -func (d *Definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (d *Definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (d *Definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - select { - case <-ctx.Done(): - return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - resolvedAssets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) - } - if req.Logger != nil { - req.Logger.Warnf("trackers: PTP description assets failed: %v", err) - } - } else { - assets = resolvedAssets - } - } - - description := strings.TrimSpace(assets.Description) - if !assets.Final { - description = buildDescription(req.Meta, req.TrackerConfig, req.AppConfig, assets) - } - if strings.TrimSpace(description) == "" && req.Logger != nil { - req.Logger.Infof("trackers: PTP preparation description empty") - } - - return trackers.DescriptionResult{ - Group: "ptp", - Description: description, - }, nil -} diff --git a/internal/trackers/impl/ptp/definition_test.go b/internal/trackers/impl/ptp/definition_test.go deleted file mode 100644 index a2d37fbf..00000000 --- a/internal/trackers/impl/ptp/definition_test.go +++ /dev/null @@ -1,646 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ptp - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/anacrolix/torrent/metainfo" - mkbrr "github.com/autobrr/mkbrr/torrent" - - "github.com/autobrr/upbrr/internal/config" - servicedb "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestDefinitionBuildDescriptionUsesPTPGroup(t *testing.T) { - d := New() - result, err := d.BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Group != "ptp" { - t.Fatalf("expected ptp group, got %q", result.Group) - } -} - -func TestDefinitionBuildDescriptionUsesResolvedAssetsAndMediaInfo(t *testing.T) { - tmp := t.TempDir() - mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") - if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - - result, err := New().BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - MediaInfoTextPath: mediaInfoPath, - Options: api.UploadOptions{Screens: 1}, - }, - Assets: &trackers.DescriptionAssets{ - Description: "kept https://pixhost.to/show/encoded.png", - Screenshots: []api.ScreenshotImage{{ - Host: "pixhost", - RawURL: "https://pixhost.to/show/encoded.png", - }}, - }, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result.Description, "[mediainfo]General\nUnique ID : 123[/mediainfo]") { - t.Fatalf("expected mediainfo section, got %q", result.Description) - } - if !strings.Contains(result.Description, "kept") || !strings.Contains(result.Description, "[img]https://pixhost.to/show/encoded.png[/img]") { - t.Fatalf("expected resolved asset description, got %q", result.Description) - } - if strings.Contains(result.Description, "lostimg.cc") { - t.Fatalf("expected no stale image hosts, got %q", result.Description) - } -} - -func TestDefinitionBuildDescriptionUsesAllResolvedPixhostScreenshots(t *testing.T) { - tmp := t.TempDir() - mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") - if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - - screenshots := []api.ScreenshotImage{ - {Host: "pixhost", RawURL: "https://pixhost.to/1.png"}, - {Host: "pixhost", RawURL: "https://pixhost.to/2.png"}, - {Host: "pixhost", RawURL: "https://pixhost.to/3.png"}, - } - result, err := New().BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - MediaInfoTextPath: mediaInfoPath, - Options: api.UploadOptions{Screens: 2}, - }, - Assets: &trackers.DescriptionAssets{Screenshots: screenshots}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, screenshot := range screenshots { - if !strings.Contains(result.Description, "[img]"+screenshot.RawURL+"[/img]") { - t.Fatalf("expected screenshot %q in description, got %q", screenshot.RawURL, result.Description) - } - } -} - -func TestDefinitionBuildDescriptionUsesAllowedNonPixhostRawScreenshots(t *testing.T) { - tmp := t.TempDir() - mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") - if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - - screenshots := []api.ScreenshotImage{ - {Host: "imgbb", RawURL: "https://i.ibb.co/raw-1/source.png", ImgURL: "https://i.ibb.co/thumb-1/source.png"}, - {Host: "onlyimage", RawURL: "https://onlyimage.org/images/raw-2.png", ImgURL: "https://onlyimage.org/images/medium-2.png"}, - {Host: "ptscreens", RawURL: "https://ptscreens.com/images/raw-3.png", ImgURL: "https://ptscreens.com/images/medium-3.png"}, - } - result, err := New().BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - MediaInfoTextPath: mediaInfoPath, - Options: api.UploadOptions{Screens: 2}, - }, - Assets: &trackers.DescriptionAssets{Screenshots: screenshots}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, screenshot := range screenshots { - if !strings.Contains(result.Description, "[img]"+screenshot.RawURL+"[/img]") { - t.Fatalf("expected raw screenshot %q in description, got %q", screenshot.RawURL, result.Description) - } - if strings.Contains(result.Description, screenshot.ImgURL) { - t.Fatalf("expected PTP description to avoid non-raw URL %q, got %q", screenshot.ImgURL, result.Description) - } - } -} - -func TestDefinitionBuildUploadDryRunForExistingGroup(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "release.torrent") - createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case ptpTorrentPath: - _ = json.NewEncoder(w).Encode(map[string]any{ - "Movies": []map[string]any{{"GroupId": "1234"}}, - }) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - entry, err := New().BuildUploadDryRun(context.Background(), trackers.UploadRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ReleaseName: "Movie.2026.1080p.BluRay.x264", - Source: "BluRay", - VideoCodec: "AVC", - ExternalIDs: api.ExternalIDs{Category: "MOVIE", IMDBID: 1234567}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Movie", Year: 2026, Poster: "https://img.example/poster.jpg", Genres: "Action"}, - }, - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - PTPAPIUser: "user", - PTPAPIKey: "key", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected dry-run error: %v", err) - } - if got := entry.Payload["groupid"]; got != "1234" { - t.Fatalf("expected existing group id, got %q", got) - } - if _, exists := entry.Payload["title"]; exists { - t.Fatal("did not expect new-group title field when group already exists") - } - if entry.Questionnaire != nil { - t.Fatal("did not expect questionnaire for existing group upload") - } -} - -func TestDefinitionBuildUploadDryRunForNewGroupIncludesQuestionnaire(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "release.torrent") - createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) - - entry, err := New().BuildUploadDryRun(context.Background(), trackers.UploadRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ReleaseName: "Movie.2026.1080p.BluRay.x264", - Source: "BluRay", - VideoCodec: "AVC", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - Title: "Movie", - Year: 2026, - Poster: "https://img.example/poster.jpg", - Genres: "Action", - Overview: "Plot", - }, - }, - }, - TrackerConfig: config.TrackerConfig{}, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected dry-run error: %v", err) - } - if entry.Questionnaire == nil { - t.Fatal("expected questionnaire for new group upload") - } - if got := len(entry.Questionnaire.Fields); got == 0 { - t.Fatal("expected questionnaire fields") - } - if entry.Questionnaire.Fields[0].Key != "title" { - t.Fatalf("expected first questionnaire field to be title, got %q", entry.Questionnaire.Fields[0].Key) - } - if got := entry.Payload["imdb"]; got != "0" { - t.Fatalf("expected missing IMDb dry-run payload value 0, got %q", got) - } -} - -func TestResolveUploadTorrentPathReusesPreparedPTPArtifact(t *testing.T) { - tmp := t.TempDir() - dbPath := filepath.Join(tmp, "ua.db") - sourcePath := filepath.Join(tmp, "Movie.mkv") - torrentPath := filepath.Join(tmp, "release.torrent") - createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) - - preparedMeta, err := trackers.PrepareTrackerUploadTorrent( - api.PreparedMetadata{SourcePath: sourcePath, TorrentPath: torrentPath}, - dbPath, - "PTP", - config.TrackerConfig{AnnounceURL: "https://please.passthepopcorn.me/passkey/announce"}, - ) - if err != nil { - t.Fatalf("prepare tracker torrent: %v", err) - } - - resolvedPath, err := resolveUploadTorrentPath(preparedMeta, dbPath) - if err != nil { - t.Fatalf("resolve upload torrent path: %v", err) - } - if resolvedPath != preparedMeta.TorrentPath { - t.Fatalf("expected prepared artifact path %q, got %q", preparedMeta.TorrentPath, resolvedPath) - } - - torrentMeta, err := metainfo.LoadFromFile(resolvedPath) - if err != nil { - t.Fatalf("load prepared artifact: %v", err) - } - if torrentMeta.Announce != "https://please.passthepopcorn.me/passkey/announce" { - t.Fatal("expected prepared announce preserved") - } - info, err := torrentMeta.UnmarshalInfo() - if err != nil { - t.Fatalf("unmarshal prepared info: %v", err) - } - if info.Source != "PTP" { - t.Fatalf("expected prepared source preserved, got %q", info.Source) - } -} - -func TestDefinitionUploadSuccess(t *testing.T) { - tmp := t.TempDir() - dbPath := newPTPAuthDB(t) - torrentPath := filepath.Join(tmp, "release.torrent") - createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) - markTorrentWithPrivateMetadata(t, torrentPath) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.RequestURI() { - case ptpLoginPath: - if err := r.ParseForm(); err != nil { - t.Errorf("parse login: %v", err) - return - } - if r.FormValue("username") != "user" || r.FormValue("password") != "pass" { - t.Error("unexpected login credentials") - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "cookievalue", Path: "/"}) - _ = json.NewEncoder(w).Encode(map[string]any{ - "Result": "Ok", - "AntiCsrfToken": "csrf-token", - }) - default: - switch r.URL.Path { - case ptpUploadPath: - if err := r.ParseMultipartForm(5 << 20); err != nil { - t.Errorf("parse multipart: %v", err) - return - } - files := r.MultipartForm.File["file_input"] - if len(files) != 1 { - t.Errorf("expected one torrent file, got %d", len(files)) - return - } - uploaded, err := files[0].Open() - if err != nil { - t.Errorf("open uploaded torrent: %v", err) - return - } - defer uploaded.Close() - payload, err := io.ReadAll(uploaded) - if err != nil { - t.Errorf("read uploaded torrent: %v", err) - return - } - uploadedMeta, err := metainfo.Load(bytes.NewReader(payload)) - if err != nil { - t.Errorf("load uploaded torrent: %v", err) - return - } - if uploadedMeta.Comment != "upbrr" { - t.Errorf("expected cleaned upload torrent comment, got %q", uploadedMeta.Comment) - return - } - if uploadedMeta.CreatedBy != "uploaded with upbrr" { - t.Errorf("expected cleaned upload torrent created-by, got %q", uploadedMeta.CreatedBy) - return - } - if uploadedMeta.Announce != "" { - t.Error("expected upload torrent announce stripped") - return - } - if r.FormValue("AntiCsrfToken") != "csrf-token" { - t.Error("expected csrf token") - return - } - if r.FormValue("title") != "Movie" { - t.Error("expected new-group title field") - return - } - http.Redirect(w, r, "/torrents.php?id=555&torrentid=666", http.StatusFound) - case ptpTorrentPath: - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - default: - w.WriteHeader(http.StatusNotFound) - } - } - })) - defer server.Close() - - result, err := New().Upload(context.Background(), trackers.UploadRequest{ - Tracker: "PTP", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - ReleaseName: "Movie.2026.1080p.BluRay.x264", - Source: "BluRay", - VideoCodec: "AVC", - ExternalIDs: api.ExternalIDs{Category: "MOVIE", IMDBID: 1234567}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - Title: "Movie", - Year: 2026, - Poster: "https://img.example/poster.jpg", - Genres: "Action", - Overview: "Plot", - }, - }, - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Logger: api.NopLogger{}, - }) - if err != nil { - t.Fatalf("unexpected upload error: %v", err) - } - if result.Uploaded != 1 { - t.Fatalf("expected uploaded=1, got %d", result.Uploaded) - } - if len(result.UploadedTorrents) != 1 { - t.Fatalf("expected uploaded torrent artifact") - } - if result.UploadedTorrents[0].TorrentID != "666" { - t.Fatalf("expected torrent id 666, got %q", result.UploadedTorrents[0].TorrentID) - } - artifactPath := result.UploadedTorrents[0].TorrentPath - if strings.TrimSpace(artifactPath) == "" { - t.Fatal("expected tracker torrent path") - } - if _, err := os.Stat(artifactPath); err != nil { - t.Fatalf("expected tracker torrent file: %v", err) - } - legacyCookiePath, err := servicedb.CookiePath(dbPath, ptpCookieFile) - if err != nil { - t.Fatalf("resolve legacy PTP cookie path: %v", err) - } - if _, err := os.Stat(legacyCookiePath); !os.IsNotExist(err) { - t.Fatalf("expected no legacy PTP cookie file after upload, got err=%v", err) - } -} - -func TestLoginAndFetchAntiCsrfTokenHandles2FA(t *testing.T) { - secondLogin := false - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.RequestURI() != ptpLoginPath { - w.WriteHeader(http.StatusNotFound) - return - } - if err := r.ParseForm(); err != nil { - t.Errorf("parse login: %v", err) - return - } - if r.FormValue("username") != "user" || r.FormValue("password") != "pass" || r.FormValue("passkey") != "passkey" || r.FormValue("keeplogged") != "1" { - t.Error("unexpected login form") - return - } - if r.FormValue("TfaCode") == "" { - _ = json.NewEncoder(w).Encode(map[string]any{"Result": "TfaRequired"}) - return - } - secondLogin = true - if r.FormValue("TfaType") != "normal" { - t.Errorf("expected TfaType normal, got %q", r.FormValue("TfaType")) - return - } - code := r.FormValue("TfaCode") - if len(code) != 6 || strings.Trim(code, "0123456789") != "" { - t.Errorf("expected six digit TfaCode, got %q", code) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "cookievalue", Path: "/"}) - _ = json.NewEncoder(w).Encode(map[string]any{ - "Result": "Ok", - "AntiCsrfToken": "csrf-token", - }) - })) - defer server.Close() - - dbPath := newPTPAuthDB(t) - client, token, err := loginAndFetchAntiCsrfToken(context.Background(), config.TrackerConfig{ - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - OTPURI: "otpauth://totp/PTP:user?secret=JBSWY3DPEHPK3PXP&issuer=PTP", - }, dbPath, server.URL, api.NopLogger{}, api.TrackerAuthLoginRequest{}) - if err != nil { - t.Fatalf("login: %v", err) - } - if token != "csrf-token" { - t.Fatalf("expected csrf token, got %q", token) - } - if client == nil { - t.Fatal("expected authenticated client") - } - if !secondLogin { - t.Fatal("expected second 2FA login request") - } -} - -func TestRehostPosterToSelectedHostUploadsPoster(t *testing.T) { - tmp := t.TempDir() - dbPath := filepath.Join(tmp, "ua.db") - sourcePath := filepath.Join(tmp, "Movie.mkv") - if err := os.WriteFile(sourcePath, []byte("movie"), 0o600); err != nil { - t.Fatalf("write source: %v", err) - } - - posterServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write([]byte("poster-bytes")) - })) - defer posterServer.Close() - originalClientFactory := newPosterHTTPClient - newPosterHTTPClient = posterServer.Client - t.Cleanup(func() { - newPosterHTTPClient = originalClientFactory - }) - - images := &stubPTPImageHosting{ - uploaded: []api.UploadedImageLink{{ - Host: "pixhost", - RawURL: "https://pixhost.to/rehosted.png", - }}, - } - got := rehostPosterToSelectedHost(context.Background(), trackers.UploadRequest{ - Meta: api.PreparedMetadata{ - SourcePath: sourcePath, - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - Logger: api.NopLogger{}, - Images: images, - }, posterServer.URL+"/poster") - - if got != "https://pixhost.to/rehosted.png" { - t.Fatalf("expected rehosted poster URL, got %q", got) - } - if images.host != "pixhost" { - t.Fatalf("expected pixhost upload host, got %q", images.host) - } - if len(images.images) != 1 { - t.Fatalf("expected one uploaded poster, got %d", len(images.images)) - } - posterPath := images.images[0].Path - if filepath.Base(posterPath) != "PTP_POSTER.png" { - t.Fatalf("expected content-type poster extension, got %q", filepath.Base(posterPath)) - } - body, err := os.ReadFile(posterPath) - if err != nil { - t.Fatalf("read poster: %v", err) - } - if string(body) != "poster-bytes" { - t.Fatalf("unexpected poster body %q", string(body)) - } -} - -func TestRehostPosterToSelectedHostRejectsLoopbackPoster(t *testing.T) { - tmp := t.TempDir() - sourcePath := filepath.Join(tmp, "Movie.mkv") - if err := os.WriteFile(sourcePath, []byte("movie"), 0o600); err != nil { - t.Fatalf("write source: %v", err) - } - posterServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("poster-bytes")) - })) - defer posterServer.Close() - - images := &stubPTPImageHosting{ - uploaded: []api.UploadedImageLink{{ - Host: "pixhost", - RawURL: "https://pixhost.to/rehosted.png", - }}, - } - got := rehostPosterToSelectedHost(context.Background(), trackers.UploadRequest{ - Meta: api.PreparedMetadata{ - SourcePath: sourcePath, - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}, - Logger: api.NopLogger{}, - Images: images, - }, posterServer.URL+"/poster") - - if got != posterServer.URL+"/poster" { - t.Fatalf("expected original poster URL after blocked rehost, got %q", got) - } - if len(images.images) != 0 { - t.Fatalf("expected no upload for blocked loopback poster") - } -} - -func TestIsPublicPosterIPRejectsPrivateRanges(t *testing.T) { - t.Parallel() - - for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.20", "169.254.1.1", "::1", "fc00::1"} { - if isPublicPosterIP(netip.MustParseAddr(value)) { - t.Fatalf("expected %s to be rejected", value) - } - } - if !isPublicPosterIP(netip.MustParseAddr("93.184.216.34")) { - t.Fatal("expected public address to be accepted") - } -} - -func TestRehostPosterToSelectedHostSkipsSelectedHost(t *testing.T) { - images := &stubPTPImageHosting{} - got := rehostPosterToSelectedHost(context.Background(), trackers.UploadRequest{ - Images: images, - }, "https://pixhost.to/existing.jpg") - if got != "https://pixhost.to/existing.jpg" { - t.Fatalf("expected original poster URL, got %q", got) - } - if len(images.images) != 0 { - t.Fatalf("expected no upload for selected host poster") - } -} - -type stubPTPImageHosting struct { - host string - scope string - images []api.ScreenshotImage - uploaded []api.UploadedImageLink -} - -func (s *stubPTPImageHosting) ListCandidates(context.Context, api.PreparedMetadata) ([]api.ScreenshotImage, error) { - return nil, nil -} - -func (s *stubPTPImageHosting) Upload(_ context.Context, _ api.PreparedMetadata, host string, usageScope string, images []api.ScreenshotImage) ([]api.UploadedImageLink, error) { - s.host = host - s.scope = usageScope - s.images = append([]api.ScreenshotImage(nil), images...) - return s.uploaded, nil -} - -func createTestTorrent(t *testing.T, sourcePath string, torrentPath string) { - t.Helper() - - if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil { - t.Fatalf("write source: %v", err) - } - _, err := mkbrr.Create(mkbrr.CreateOptions{ - Path: sourcePath, - OutputPath: torrentPath, - IsPrivate: true, - }) - if err != nil { - t.Fatalf("create torrent: %v", err) - } -} - -func markTorrentWithPrivateMetadata(t *testing.T, torrentPath string) { - t.Helper() - - torrentMeta, err := metainfo.LoadFromFile(torrentPath) - if err != nil { - t.Fatalf("load torrent: %v", err) - } - torrentMeta.Announce = "https://private.example/passkey/announce" - torrentMeta.Comment = "Created by Upload Assistant https://private.example/download/1" - file, err := os.Create(torrentPath) - if err != nil { - t.Fatalf("rewrite torrent: %v", err) - } - defer file.Close() - if err := torrentMeta.Write(file); err != nil { - t.Fatalf("write torrent: %v", err) - } -} diff --git a/internal/trackers/impl/ptp/upload.go b/internal/trackers/impl/ptp/upload.go deleted file mode 100644 index 9deecbc7..00000000 --- a/internal/trackers/impl/ptp/upload.go +++ /dev/null @@ -1,1513 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ptp - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. - "encoding/base32" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net" - "net/http" - "net/http/cookiejar" - "net/netip" - "net/url" - "os" - "path" //nolint:depguard // Reads poster URL path extension, not local filesystem extension. - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - cookiepkg "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/paths" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/redaction" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/services/imagehost" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - ptpBaseURL = "https://passthepopcorn.me" - ptpUploadPath = "/upload.php" - ptpTorrentPath = "/torrents.php" - ptpLoginPath = "/ajax.php?action=login" - ptpCookieFile = "PTP.json" - ptpUserAgent = "upbrr" -) - -var ( - ptpAntiCsrfPattern = regexp.MustCompile(`data-AntiCsrfToken="([^"]+)"`) - ptpSuccessPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)&torrentid=(\d+)`) - newPosterHTTPClient = newPublicPosterHTTPClient - reservedPosterPrefixes = []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/8"), - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("192.0.0.0/24"), - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("198.18.0.0/15"), - netip.MustParsePrefix("198.51.100.0/24"), - netip.MustParsePrefix("203.0.113.0/24"), - netip.MustParsePrefix("240.0.0.0/4"), - netip.MustParsePrefix("2001:db8::/32"), - netip.MustParsePrefix("fc00::/7"), - netip.MustParsePrefix("fe80::/10"), - } -) - -// ErrSubmitted2FARejected marks a PTP failure after a submitted manual 2FA code -// reached the tracker and was rejected. -var ErrSubmitted2FARejected = errors.New("trackers: PTP submitted 2FA rejected") - -type uploadState struct { - baseURL string - uploadURL string - announceURL string - client *http.Client - groupID string - torrentPath string - description string - fields map[string]string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req, false) - if err != nil { - return api.UploadSummary{}, err - } - - body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath, "file_input") - if err != nil { - return api.UploadSummary{}, err - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTP request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", ptpUserAgent) - - resp, err := state.client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTP upload request: %w", err) - } - defer resp.Body.Close() - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - _, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTP read upload response: %w", err) - } - if matches := ptpSuccessPattern.FindStringSubmatch(finalURL); len(matches) == 3 { - groupID := strings.TrimSpace(matches[1]) - torrentID := strings.TrimSpace(matches[2]) - torrentURL := strings.TrimRight(state.baseURL, "/") + "/torrents.php?id=" + url.QueryEscape(groupID) + "&torrentid=" + url.QueryEscape(torrentID) - trackerTorrentPath, err := resolveTrackerTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath, "PTP") - if err != nil { - return api.UploadSummary{}, err - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, trackerTorrentPath, state.announceURL, torrentURL, "PTP"); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTP write torrent artifact: %w", err) - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "PTP", - TorrentID: torrentID, - TorrentURL: torrentURL, - DownloadURL: torrentURL, - TorrentPath: trackerTorrentPath, - }}, - }, nil - } - - failurePath := "" - if pathValue, pathErr := resolveFailurePath(req.Meta, req.AppConfig.MainSettings.DBPath); pathErr == nil { - failurePath = pathValue - redactedBody := []byte(redaction.RedactValue(string(responsePreview), nil)) - _ = os.WriteFile(failurePath, redactedBody, 0o600) - } - errText := commonhttp.RedactErrorDetail(extractAlertError(string(responsePreview))) - if errText == "" { - errText = commonhttp.ExtractHTTPErrorDetail(responsePreview) - } - if errText == "" { - errText = "upload failed" - } - if failurePath != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: PTP upload failed status=%d url=%s error=%s failure=%s", resp.StatusCode, commonhttp.RedactErrorDetail(finalURL), compactError(errText), failurePath) - } - return api.UploadSummary{}, fmt.Errorf("trackers: PTP upload failed status=%d url=%s error=%s", resp.StatusCode, commonhttp.RedactErrorDetail(finalURL), compactError(errText)) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req, true) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - message := "dry-run payload generated" - if state.groupID != "" { - message += " for existing group" - } else { - message += " for new group" - } - return api.TrackerDryRunEntry{ - Tracker: "PTP", - Status: "ready", - Message: message, - ReleaseName: resolveUploadName(req.Meta), - DescriptionGroup: "ptp", - Description: state.description, - Endpoint: state.uploadURL, - Payload: state.fields, - Files: []api.TrackerDryRunFile{{ - Field: "file_input", - Path: state.torrentPath, - Present: strings.TrimSpace(state.torrentPath) != "", - }}, - Questionnaire: buildQuestionnaire(req.Meta, state.groupID), - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest, dryRun bool) (uploadState, error) { - baseURL := strings.TrimRight(strings.TrimSpace(req.TrackerConfig.URL), "/") - if baseURL == "" { - baseURL = ptpBaseURL - } - announceURL := normalizedAnnounceURL(req.TrackerConfig.AnnounceURL) - torrentPath, err := resolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: PTP resolve upload torrent: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, req.TrackerConfig, req.AppConfig, assets) - groupID, err := lookupGroupID(ctx, baseURL, req.TrackerConfig, req.Meta) - if err != nil { - return uploadState{}, err - } - answers := questionnaireAnswers(req.Meta, "PTP") - poster := metautil.FirstNonEmptyTrimmed(answers["poster"], resolvePoster(req.Meta)) - if !dryRun { - poster = rehostPosterToSelectedHost(ctx, req, poster) - } - fields, err := buildUploadFields(req.Meta, description, groupID, answers, poster) - if err != nil { - return uploadState{}, err - } - fields["AntiCsrfToken"] = "dry-run-token" - - var client *http.Client - if !dryRun { - client, fields["AntiCsrfToken"], err = resolveSession(ctx, req.TrackerConfig, req.AppConfig.MainSettings.DBPath, baseURL, req.Logger) - if err != nil { - return uploadState{}, err - } - } - - return uploadState{ - baseURL: baseURL, - uploadURL: baseURL + ptpUploadPath, - announceURL: announceURL, - client: client, - groupID: groupID, - torrentPath: torrentPath, - description: description, - fields: fields, - }, nil -} - -func resolveUploadTorrentPath(meta api.PreparedMetadata, dbPath string) (string, error) { - preparedPath, err := trackers.ResolveTrackerTorrentArtifactPath(meta, dbPath, "PTP") - if err == nil && strings.TrimSpace(meta.TorrentPath) == preparedPath { - if info, statErr := os.Stat(preparedPath); statErr == nil && !info.IsDir() { - return preparedPath, nil - } - } - path, err := trackers.ResolveUploadTorrentPath(meta, dbPath) - if err != nil { - return "", fmt.Errorf("trackers: resolve upload torrent path: %w", err) - } - return path, nil -} - -func buildDescription(meta api.PreparedMetadata, trackerConfig config.TrackerConfig, appConfig config.Config, assets trackers.DescriptionAssets) string { - baseDescription := strings.TrimSpace(assets.Description) - if assets.Final { - return baseDescription - } - if baseDescription != "" { - report := bbcode.CleanPTPDescription(baseDescription, meta.DiscType) - baseDescription = strings.TrimSpace(report.Description) - } - - var sections []string - if mediaSection, err := buildMediaSection(meta, appConfig.MainSettings.DBPath); err == nil && mediaSection != "" { - sections = append(sections, mediaSection) - } - if strings.TrimSpace(baseDescription) != "" { - sections = append(sections, convertDescription(baseDescription)) - } - if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && strings.TrimSpace(meta.ServiceLongName) != "" && trackerConfig.AddWebSourceToDesc { - sections = append(sections, fmt.Sprintf("[quote][align=center]This release is sourced from %s[/align][/quote]", strings.TrimSpace(meta.ServiceLongName))) - } - if shots := buildScreenshotSection(meta, assets.Screenshots); shots != "" { - sections = append(sections, shots) - } - return strings.TrimSpace(strings.Join(sections, "\n\n")) -} - -func buildMediaSection(meta api.PreparedMetadata, dbPath string) (string, error) { - switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { - case "BDMV": - text, err := readBDSummary(meta, dbPath) - if err != nil { - return "", err - } - if strings.TrimSpace(text) == "" { - return "", nil - } - return "[mediainfo]" + strings.TrimSpace(text) + "[/mediainfo]", nil - default: - text, err := readTextFile(strings.TrimSpace(meta.MediaInfoTextPath)) - if err != nil { - return "", err - } - if strings.TrimSpace(text) == "" { - return "", nil - } - return "[mediainfo]" + strings.TrimSpace(text) + "[/mediainfo]", nil - } -} - -func buildScreenshotSection(meta api.PreparedMetadata, screenshots []api.ScreenshotImage) string { - if len(screenshots) == 0 { - return "" - } - minimum := meta.Options.Screens - if minimum <= 0 { - minimum = 2 - } - if requiresMinimumTwoScreens(meta) && minimum < 2 { - minimum = 2 - } - allowed := make([]string, 0, len(screenshots)) - for _, screenshot := range screenshots { - rawURL := strings.TrimSpace(screenshot.RawURL) - if rawURL == "" { - rawURL = strings.TrimSpace(screenshot.ImgURL) - } - if rawURL == "" { - continue - } - host := strings.ToLower(strings.TrimSpace(screenshot.Host)) - if host == "" { - host = imagehost.ExtractHost(rawURL) - } - if !isPTPImageHost(host) { - continue - } - allowed = append(allowed, "[img]"+rawURL+"[/img]") - } - if len(allowed) < minimum { - return "" - } - return strings.Join(allowed, "\n") -} - -func isPTPImageHost(host string) bool { - switch strings.ToLower(strings.TrimSpace(host)) { - case "pixhost", "imgbb", "onlyimage", "ptscreens": - return true - default: - return false - } -} - -func requiresMinimumTwoScreens(meta api.PreparedMetadata) bool { - if len(meta.FileList) > 1 { - return true - } - return strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") -} - -func convertDescription(value string) string { - replacer := strings.NewReplacer( - "[spoiler", "[hide", - "[/spoiler]", "[/hide]", - "[center]", "[align=center]", - "[/center]", "[/align]", - "[left]", "[align=left]", - "[/left]", "[/align]", - "[right]", "[align=right]", - "[/right]", "[/align]", - "[h1]", "[u][b]", - "[/h1]", "[/b][/u]", - "[h2]", "[u][b]", - "[/h2]", "[/b][/u]", - "[h3]", "[u][b]", - "[/h3]", "[/b][/u]", - ) - return replacer.Replace(strings.TrimSpace(value)) -} - -func lookupGroupID(ctx context.Context, baseURL string, trackerConfig config.TrackerConfig, meta api.PreparedMetadata) (string, error) { - apiUser := strings.TrimSpace(trackerConfig.PTPAPIUser) - apiKey := strings.TrimSpace(trackerConfig.PTPAPIKey) - if apiUser == "" || apiKey == "" || meta.ExternalIDs.IMDBID == 0 { - return "", nil - } - headers := map[string]string{ - "ApiUser": apiUser, - "ApiKey": apiKey, - "User-Agent": ptpUserAgent, - } - values := url.Values{} - values.Set("imdb", fmt.Sprintf("tt%07d", meta.ExternalIDs.IMDBID)) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+ptpTorrentPath+"?"+values.Encode(), nil) - if err != nil { - return "", fmt.Errorf("trackers: PTP build group lookup request: %w", err) - } - for key, value := range headers { - httpReq.Header.Set(key, value) - } - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return "", nil - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", nil - } - var payload map[string]any - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", nil - } - if movies, ok := payload["Movies"].([]any); ok && len(movies) > 0 { - if movie, ok := movies[0].(map[string]any); ok { - if groupID := stringFromAny(movie["GroupId"]); groupID != "" { - return groupID, nil - } - } - } - return stringFromAny(payload["GroupId"]), nil -} - -func rehostPosterToSelectedHost(ctx context.Context, req trackers.UploadRequest, imageURL string) string { - trimmedURL := strings.TrimSpace(imageURL) - if trimmedURL == "" { - return "" - } - if req.Images == nil { - return trimmedURL - } - if req.Meta.ImageHostOverrides.SkipUpload != nil && *req.Meta.ImageHostOverrides.SkipUpload { - return trimmedURL - } - - selectedHost, err := trackers.PreferredImageUploadHost("PTP", req.TrackerConfig, req.Meta.ImageHostOverrides) - if err != nil { - logPosterRehostFailure(req.Logger, "", err) - return trimmedURL - } - selectedHost = strings.ToLower(strings.TrimSpace(selectedHost)) - if selectedHost == "" { - return trimmedURL - } - if strings.EqualFold(strings.TrimSpace(imagehost.ExtractHost(trimmedURL)), selectedHost) { - return trimmedURL - } - - posterPath, err := downloadPoster(ctx, req.Meta, req.AppConfig.MainSettings.DBPath, trimmedURL) - if err != nil { - logPosterRehostFailure(req.Logger, selectedHost, err) - return trimmedURL - } - uploaded, err := req.Images.Upload(ctx, req.Meta, selectedHost, "global", []api.ScreenshotImage{{Path: posterPath}}) - if err != nil { - logPosterRehostFailure(req.Logger, selectedHost, err) - return trimmedURL - } - if len(uploaded) == 0 { - logPosterRehostFailure(req.Logger, selectedHost, errors.New("upload returned no links")) - return trimmedURL - } - uploadedURL := metautil.FirstNonEmptyTrimmed(uploaded[0].RawURL, uploaded[0].ImgURL, uploaded[0].WebURL) - if strings.TrimSpace(uploadedURL) == "" { - logPosterRehostFailure(req.Logger, selectedHost, errors.New("upload returned blank link")) - return trimmedURL - } - if req.Logger != nil { - req.Logger.Infof("trackers: PTP poster rehosted to %s", selectedHost) - } - return strings.TrimSpace(uploadedURL) -} - -func downloadPoster(ctx context.Context, meta api.PreparedMetadata, dbPath string, imageURL string) (string, error) { - if err := validatePosterURL(imageURL); err != nil { - return "", err - } - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - return "", fmt.Errorf("poster request: %w", err) - } - httpReq.Header.Set("User-Agent", ptpUserAgent) - - client := newPosterHTTPClient() - resp, err := client.Do(httpReq) - if err != nil { - return "", fmt.Errorf("poster download: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("poster download status=%d", resp.StatusCode) - } - - const maxPosterBytes = 25 << 20 - body, err := io.ReadAll(io.LimitReader(resp.Body, maxPosterBytes+1)) - if err != nil { - return "", fmt.Errorf("poster read: %w", err) - } - if len(body) == 0 { - return "", errors.New("poster download returned empty body") - } - if len(body) > maxPosterBytes { - return "", errors.New("poster exceeds maximum size") - } - - posterPath := filepath.Join(tmpDir, "PTP_POSTER"+posterExtension(imageURL, resp.Header.Get("Content-Type"))) - if err := os.WriteFile(posterPath, body, 0o600); err != nil { - return "", fmt.Errorf("poster write: %w", err) - } - return posterPath, nil -} - -func posterExtension(imageURL string, contentType string) string { - if parsed, err := url.Parse(imageURL); err == nil { - switch ext := strings.ToLower(path.Ext(parsed.Path)); ext { - case ".jpg", ".jpeg", ".png", ".webp": - return ext - } - } - mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) - switch mediaType { - case "image/png": - return ".png" - case "image/webp": - return ".webp" - case "image/jpeg": - return ".jpg" - default: - return ".jpg" - } -} - -func validatePosterURL(rawURL string) error { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil { - return fmt.Errorf("poster URL: %w", err) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New("poster URL must use http or https") - } - if strings.TrimSpace(parsed.Hostname()) == "" { - return errors.New("poster URL host is required") - } - return nil -} - -func newPublicPosterHTTPClient() *http.Client { - dialer := &net.Dialer{Timeout: 30 * time.Second} - transport := &http.Transport{ - DialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, fmt.Errorf("poster dial address: %w", err) - } - target, err := resolvePublicPosterAddress(ctx, host, port) - if err != nil { - return nil, err - } - return dialer.DialContext(ctx, network, target) - }, - } - return &http.Client{Timeout: 30 * time.Second, Transport: transport} -} - -func resolvePublicPosterAddress(ctx context.Context, host string, port string) (string, error) { - if ip := net.ParseIP(host); ip != nil { - addr, ok := netip.AddrFromSlice(ip) - if !ok || !isPublicPosterIP(addr.Unmap()) { - return "", fmt.Errorf("poster host %q resolves to non-public IP", host) - } - return net.JoinHostPort(addr.String(), port), nil - } - resolved, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return "", fmt.Errorf("poster DNS lookup %q: %w", host, err) - } - for _, candidate := range resolved { - addr, ok := netip.AddrFromSlice(candidate.IP) - if !ok { - continue - } - addr = addr.Unmap() - if isPublicPosterIP(addr) { - return net.JoinHostPort(addr.String(), port), nil - } - } - return "", fmt.Errorf("poster host %q has no public IP addresses", host) -} - -func isPublicPosterIP(ip netip.Addr) bool { - if !ip.IsValid() || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { - return false - } - return !ipInPrefixes(ip, reservedPosterPrefixes) -} - -func ipInPrefixes(ip netip.Addr, prefixes []netip.Prefix) bool { - for _, prefix := range prefixes { - if prefix.Contains(ip) { - return true - } - } - return false -} - -func logPosterRehostFailure(logger api.Logger, host string, err error) { - if logger == nil || err == nil { - return - } - if strings.TrimSpace(host) == "" { - logger.Warnf("trackers: PTP poster rehost failed: %v", err) - return - } - logger.Warnf("trackers: PTP poster rehost to %s failed: %v", strings.TrimSpace(host), err) -} - -func buildUploadFields(meta api.PreparedMetadata, description string, groupID string, answers map[string]string, poster string) (map[string]string, error) { - resolution, otherResolution := resolveResolution(meta) - fields := map[string]string{ - "submit": "true", - "remaster_year": "", - "remaster_title": resolveRemasterTitle(meta), - "type": resolveType(meta), - "codec": "Other", - "other_codec": resolveCodec(meta), - "container": "Other", - "other_container": resolveContainer(meta), - "resolution": resolution, - "source": "Other", - "other_source": resolveSource(meta.Source), - "release_desc": description, - "nfo_text": "", - "subtitles[]": joinInts(resolveSubtitles(meta)), - "trumpable[]": "", - } - if resolution == "Other" && otherResolution != "" { - fields["other_resolution"] = otherResolution - } - if fields["remaster_title"] != "" { - fields["remaster"] = "on" - } - if meta.Scene { - fields["scene"] = "on" - } - if meta.PersonalRelease { - fields["internalrip"] = "on" - } - if meta.ExternalIDs.IMDBID == 0 { - fields["imdb"] = "0" - } else { - fields["imdb"] = fmt.Sprintf("%07d", meta.ExternalIDs.IMDBID) - } - if groupID != "" { - fields["groupid"] = groupID - return fields, nil - } - - title, year := resolveGroupTitleYear(meta) - title = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["title"]), title) - year = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["year"]), year) - if strings.TrimSpace(title) == "" { - return nil, errors.New("trackers: PTP missing title for new group upload") - } - fields["title"] = title - fields["year"] = year - fields["image"] = strings.TrimSpace(poster) - fields["tags"] = metautil.FirstNonEmptyTrimmed(answers["tags"], resolveTags(meta)) - fields["album_desc"] = metautil.FirstNonEmptyTrimmed(answers["album_desc"], resolveOverview(meta)) - fields["trailer"] = metautil.FirstNonEmptyTrimmed(answers["trailer"], resolveTrailer(meta)) - directors := resolveDirectors(meta) - if len(directors) > 0 { - fields["artist[]"] = strings.Join(directors, "\n") - fields["importance[]"] = "1" - } - if fields["image"] == "" { - return nil, errors.New("trackers: PTP missing poster for new group upload") - } - return fields, nil -} - -func resolveSession(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, baseURL string, logger api.Logger) (*http.Client, string, error) { - return resolveSessionLogin(ctx, trackerConfig, dbPath, baseURL, logger, api.TrackerAuthLoginRequest{}) -} - -func resolveSessionLogin(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, baseURL string, logger api.Logger, login api.TrackerAuthLoginRequest) (*http.Client, string, error) { - if logger == nil { - logger = api.NopLogger{} - } - - cookies, err := loadCookies(ctx, dbPath) - if err == nil && len(cookies) > 0 { - client, token, tokenErr := fetchAntiCsrfToken(ctx, baseURL, cookies) - if tokenErr == nil { - return client, token, nil - } - if strings.TrimSpace(trackerConfig.Username) == "" || strings.TrimSpace(trackerConfig.Password) == "" || strings.TrimSpace(normalizedAnnounceURL(trackerConfig.AnnounceURL)) == "" { - return nil, "", tokenErr - } - } - return loginAndFetchAntiCsrfToken(ctx, trackerConfig, dbPath, baseURL, logger, login) -} - -// ResolveSessionForTrackerAuth validates PTP stored cookies or logs in with -// configured credentials. Credential login must produce an anti-CSRF token -// before refreshed cookies are persisted. -func ResolveSessionForTrackerAuth(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string) error { - return ResolveSessionForTrackerAuthLogin(ctx, trackerConfig, dbPath, api.TrackerAuthLoginRequest{}) -} - -// ResolveSessionForTrackerAuthLogin validates PTP stored cookies or logs in -// with configured credentials. When PTP requires 2FA, login.Code is used before -// falling back to the configured OTP URI; if neither yields a code, the error -// is returned before stored cookies are replaced. A rejected submitted code -// returns [ErrSubmitted2FARejected] with the login-failed error before refreshed -// cookies are persisted. -func ResolveSessionForTrackerAuthLogin(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { - baseURL := strings.TrimRight(strings.TrimSpace(trackerConfig.URL), "/") - if baseURL == "" { - baseURL = ptpBaseURL - } - _, _, err := resolveSessionLogin(ctx, trackerConfig, dbPath, baseURL, api.NopLogger{}, login) - return err -} - -func fetchAntiCsrfToken(ctx context.Context, baseURL string, cookies map[string]string) (*http.Client, string, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP create session cookie jar: %w", err) - } - parsed, err := url.Parse(baseURL) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP parse base URL: %w", err) - } - jarCookies := make([]*http.Cookie, 0, len(cookies)) - for name, value := range cookies { - if strings.TrimSpace(name) == "" || strings.TrimSpace(value) == "" { - continue - } - // #nosec G124 -- Outbound tracker jar cookie mirrors configured PTP session values. - jarCookies = append(jarCookies, &http.Cookie{Name: name, Value: value, Path: "/", Domain: parsed.Hostname()}) - } - jar.SetCookies(parsed, jarCookies) - client := &http.Client{Timeout: 30 * time.Second, Jar: jar} - token, err := requestAntiCsrfToken(ctx, client, baseURL) - if err != nil { - return nil, "", err - } - return client, token, nil -} - -func loginAndFetchAntiCsrfToken(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, baseURL string, _ api.Logger, login api.TrackerAuthLoginRequest) (*http.Client, string, error) { - username := strings.TrimSpace(trackerConfig.Username) - password := strings.TrimSpace(trackerConfig.Password) - announceURL := normalizedAnnounceURL(trackerConfig.AnnounceURL) - if username == "" || password == "" || announceURL == "" { - return nil, "", errors.New("trackers: PTP requires username, password, and announce_url") - } - passkey, err := passkeyFromAnnounce(announceURL) - if err != nil { - return nil, "", err - } - - jar, err := cookiejar.New(nil) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP create login cookie jar: %w", err) - } - client := &http.Client{Timeout: 30 * time.Second, Jar: jar} - form := url.Values{ - "username": {username}, - "password": {password}, - "passkey": {passkey}, - "keeplogged": {"1"}, - } - loginURL := strings.TrimRight(baseURL, "/") + ptpLoginPath - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP build login request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - httpReq.Header.Set("User-Agent", ptpUserAgent) - resp, err := client.Do(httpReq) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP login request: %w", err) - } - defer resp.Body.Close() - var payload map[string]any - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, "", fmt.Errorf("trackers: PTP login decode: %w", err) - } - switch strings.TrimSpace(stringFromAny(payload["Result"])) { - case "Ok": - case "TfaRequired": - code, codeErr := resolvePTP2FACode(trackerConfig, login) - if codeErr != nil { - return nil, "", fmt.Errorf("trackers: PTP 2FA required: %w", codeErr) - } - form.Set("TfaType", "normal") - form.Set("TfaCode", code) - httpReq, err = http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP build 2FA request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - httpReq.Header.Set("User-Agent", ptpUserAgent) - resp, err = client.Do(httpReq) - if err != nil { - return nil, "", fmt.Errorf("trackers: PTP 2FA request: %w", err) - } - defer resp.Body.Close() - payload = map[string]any{} - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, "", fmt.Errorf("trackers: PTP 2FA decode: %w", err) - } - if strings.TrimSpace(stringFromAny(payload["Result"])) != "Ok" { - return nil, "", fmt.Errorf("trackers: PTP login failed: %w", ErrSubmitted2FARejected) - } - default: - return nil, "", errors.New("trackers: PTP login failed") - } - - token := strings.TrimSpace(stringFromAny(payload["AntiCsrfToken"])) - if token == "" { - return nil, "", errors.New("trackers: PTP login missing anti csrf token") - } - if err := saveCookies(ctx, dbPath, client, baseURL); err != nil { - return nil, "", fmt.Errorf("trackers: PTP persist login cookies: %w", err) - } - return client, token, nil -} - -// resolvePTP2FACode prefers a submitted manual code so users can continue a -// browser-visible challenge when no reusable OTP URI is configured. -func resolvePTP2FACode(trackerConfig config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { - if code := strings.TrimSpace(login.Code); code != "" { - return code, nil - } - return resolve2FACode(trackerConfig.OTPURI) -} - -func requestAntiCsrfToken(ctx context.Context, client *http.Client, baseURL string) (string, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+ptpUploadPath, nil) - if err != nil { - return "", fmt.Errorf("trackers: PTP build upload page request: %w", err) - } - httpReq.Header.Set("User-Agent", ptpUserAgent) - resp, err := client.Do(httpReq) - if err != nil { - return "", fmt.Errorf("trackers: PTP upload page: %w", err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("trackers: PTP read upload page: %w", err) - } - matches := ptpAntiCsrfPattern.FindStringSubmatch(string(body)) - if len(matches) < 2 { - return "", errors.New("trackers: PTP anti csrf token not found") - } - return strings.TrimSpace(matches[1]), nil -} - -func buildMultipartPayload(fields map[string]string, torrentPath string, fileField string) ([]byte, string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - keys := make([]string, 0, len(fields)) - for key := range fields { - keys = append(keys, key) - } - slices.Sort(keys) - for _, key := range keys { - if key == "artist[]" { - for value := range strings.SplitSeq(fields[key], "\n") { - if strings.TrimSpace(value) == "" { - continue - } - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) - } - } - continue - } - if key == "subtitles[]" || key == "trumpable[]" { - for value := range strings.SplitSeq(fields[key], ",") { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - if err := writer.WriteField(key, trimmed); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) - } - } - continue - } - if err := writer.WriteField(key, fields[key]); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) - } - } - - file, err := os.Open(torrentPath) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP open torrent file: %w", err) - } - defer file.Close() - part, err := writer.CreateFormFile(fileField, "placeholder.torrent") - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP create torrent form file: %w", err) - } - if _, err := io.Copy(part, file); err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("trackers: PTP copy torrent file: %w", err) - } - if err := writer.Close(); err != nil { - return nil, "", fmt.Errorf("trackers: PTP close multipart writer: %w", err) - } - return body.Bytes(), writer.FormDataContentType(), nil -} - -func resolveTrackerTorrentPath(meta api.PreparedMetadata, dbPath string, tracker string) (string, error) { - if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { - return "", errors.New("trackers: PTP tracker torrent path requires db path and source path") - } - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - return filepath.Join(tmpDir, base+"."+strings.ToLower(strings.TrimSpace(tracker))+".torrent"), nil -} - -func resolveFailurePath(meta api.PreparedMetadata, dbPath string) (string, error) { - if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { - return "", errors.New("trackers: PTP failure path requires db path and source path") - } - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - return filepath.Join(tmpDir, "[PTP]upload_failure.html"), nil -} - -func loadCookies(ctx context.Context, dbPath string) (map[string]string, error) { - values, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "PTP") - if err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - return values, nil -} - -func saveCookies(ctx context.Context, dbPath string, client *http.Client, baseURL string) error { - if client == nil || client.Jar == nil { - return errors.New("trackers: PTP login returned no cookie jar") - } - parsed, err := url.Parse(baseURL) - if err != nil { - return fmt.Errorf("trackers: PTP parse cookie URL: %w", err) - } - cookies := make(map[string]string) - for _, cookie := range client.Jar.Cookies(parsed) { - if cookie == nil || strings.TrimSpace(cookie.Name) == "" { - continue - } - if strings.TrimSpace(cookie.Value) == "" { - continue - } - cookies[strings.TrimSpace(cookie.Name)] = cookie.Value - } - if len(cookies) == 0 { - return errors.New("trackers: PTP login returned no usable cookies") - } - return wrapTrackerError(cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", cookies)) -} - -func passkeyFromAnnounce(announceURL string) (string, error) { - parsed, err := url.Parse(announceURL) - if err != nil { - return "", fmt.Errorf("trackers: PTP parse announce URL: %w", err) - } - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) < 2 { - return "", errors.New("trackers: PTP failed to extract passkey from announce_url") - } - return parts[0], nil -} - -func resolve2FACode(otpURI string) (string, error) { - trimmed := strings.TrimSpace(otpURI) - if trimmed == "" { - return "", errors.New("otp_uri not configured") - } - parsed, err := url.Parse(trimmed) - if err != nil { - return "", fmt.Errorf("trackers: PTP parse otp_uri: %w", err) - } - secret := strings.TrimSpace(parsed.Query().Get("secret")) - if secret == "" { - return "", errors.New("otp_uri missing secret") - } - period := 30 - if value := strings.TrimSpace(parsed.Query().Get("period")); value != "" { - if parsedValue, parseErr := strconv.Atoi(value); parseErr == nil && parsedValue > 0 { - period = parsedValue - } - } - decoder := base32.StdEncoding.WithPadding(base32.NoPadding) - secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) - if err != nil { - return "", fmt.Errorf("trackers: PTP decode otp secret: %w", err) - } - counterTime := time.Now().Unix() / int64(period) - if counterTime < 0 { - return "", errors.New("totp counter before unix epoch") - } - counter := uint64(counterTime) - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, counter) - mac := hmac.New(sha1.New, secretBytes) - _, _ = mac.Write(buf) - hash := mac.Sum(nil) - offset := hash[len(hash)-1] & 0x0f - code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) - return fmt.Sprintf("%06d", code%1000000), nil -} - -func resolveUploadName(meta api.PreparedMetadata) string { - if name := strings.TrimSpace(meta.ReleaseName); name != "" { - return name - } - if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { - return name - } - if name := strings.TrimSpace(meta.Filename); name != "" { - return name - } - return pathutil.Base(meta.SourcePath) -} - -func resolveType(meta api.PreparedMetadata) string { - category := strings.ToLower(strings.TrimSpace(meta.ExternalIDs.Category)) - if category == "" { - category = strings.ToLower(strings.TrimSpace(meta.MediaInfoCategory)) - } - if meta.ExternalMetadata.IMDB != nil && strings.Contains(strings.ToLower(meta.ExternalMetadata.IMDB.Type), "concert") { - return "Music" - } - if meta.ExternalMetadata.TMDB != nil && (strings.Contains(strings.ToLower(meta.ExternalMetadata.TMDB.Genres), "documentary") || strings.Contains(strings.ToLower(meta.ExternalMetadata.TMDB.Keywords), "documentary")) { - return "Documentary" - } - if category == "movie" { - return "Feature Film" - } - if category == "tv" { - if meta.TVPack { - return "Miniseries" - } - return "Short Film" - } - return "Feature Film" -} - -func resolveCodec(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - return "BD50" - } - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - return "DVD9" - } - codec := strings.TrimSpace(meta.VideoCodec) - if codec == "" { - codec = strings.TrimSpace(meta.VideoEncode) - } - replacer := strings.NewReplacer("AVC", "H.264", "HEVC", "H.265") - codec = replacer.Replace(codec) - if meta.HasEncodeSettings { - codec = strings.ReplaceAll(codec, "H.", "x") - } - if codec == "" { - return "Other" - } - return codec -} - -func resolveResolution(meta api.PreparedMetadata) (string, string) { - resolution := strings.TrimSpace(meta.Release.Resolution) - if resolution == "" { - resolution = "Other" - } - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - source := strings.TrimSpace(meta.Source) - source = strings.ReplaceAll(source, " DVD", "") - if source != "" { - return source, "" - } - } - if strings.EqualFold(resolution, "OTHER") { - return "Other", "Other" - } - return resolution, "" -} - -func resolveContainer(meta api.PreparedMetadata) string { - switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { - case "BDMV": - return "m2ts" - case "DVD": - return "VOB IFO" - default: - switch strings.ToLower(filepath.Ext(firstFile(meta))) { - case ".mkv": - return "MKV" - case ".mp4": - return "MP4" - default: - return "Other" - } - } -} - -func resolveSource(source string) string { - switch strings.TrimSpace(source) { - case "Blu-ray", "BluRay": - return "Blu-ray" - case "HD DVD", "HDDVD": - return "HD-DVD" - case "Web": - return "WEB" - case "HDTV", "UHDTV": - return "HDTV" - case "NTSC", "PAL": - return "DVD" - default: - return "OtherR" - } -} - -func resolveSubtitles(meta api.PreparedMetadata) []int { - if len(meta.SubtitleLanguages) == 0 { - return []int{44} - } - ids := make([]int, 0, len(meta.SubtitleLanguages)) - seen := make(map[int]struct{}) - for _, language := range meta.SubtitleLanguages { - if value, ok := subtitleIDs[strings.ToLower(strings.TrimSpace(language))]; ok { - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - ids = append(ids, value) - } - } - if len(ids) == 0 { - return []int{44} - } - return ids -} - -func resolveRemasterTitle(meta api.PreparedMetadata) string { - parts := make([]string, 0, 8) - distributor := strings.ToUpper(strings.TrimSpace(meta.Distributor)) - switch distributor { - case "WARNER ARCHIVE", "WARNER ARCHIVE COLLECTION", "WAC": - parts = append(parts, "Warner Archive Collection") - case "CRITERION", "CRITERION COLLECTION", "CC": - parts = append(parts, "The Criterion Collection") - case "MASTERS OF CINEMA", "MOC": - parts = append(parts, "Masters of Cinema") - } - edition := strings.TrimSpace(meta.Edition) - switch { - case strings.Contains(strings.ToLower(edition), "director's cut"): - parts = append(parts, "Director's Cut") - case strings.Contains(strings.ToLower(edition), "extended"): - parts = append(parts, "Extended Edition") - case strings.Contains(strings.ToLower(edition), "theatrical"): - parts = append(parts, "Theatrical Cut") - case strings.Contains(strings.ToLower(edition), "uncut"): - parts = append(parts, "Uncut") - case strings.Contains(strings.ToLower(edition), "unrated"): - parts = append(parts, "Unrated") - case edition != "": - parts = append(parts, edition) - } - if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { - parts = append(parts, "Remux") - } - audio := strings.TrimSpace(meta.Audio) - if strings.Contains(audio, "DTS:X") { - parts = append(parts, "DTS:X") - } - if strings.Contains(audio, "Atmos") { - parts = append(parts, "Dolby Atmos") - } - if strings.Contains(audio, "Dual") { - parts = append(parts, "Dual Audio") - } - if strings.Contains(audio, "Dubbed") { - parts = append(parts, "English Dub") - } - if meta.HDR == "" && meta.BitDepth == "10" { - parts = append(parts, "10-bit") - } - if strings.Contains(meta.HDR, "DV") { - parts = append(parts, "Dolby Vision") - } - if strings.Contains(meta.HDR, "HDR10+") { - parts = append(parts, "HDR10+") - } else if strings.Contains(meta.HDR, "HDR") { - parts = append(parts, "HDR10") - } - if strings.Contains(meta.HDR, "HLG") { - parts = append(parts, "HLG") - } - if meta.HasCommentary { - parts = append(parts, "With Commentary") - } - return strings.Join(parts, " / ") -} - -func resolveGroupTitleYear(meta api.PreparedMetadata) (string, string) { - title := "" - year := 0 - if meta.ExternalMetadata.TMDB != nil { - title = strings.TrimSpace(meta.ExternalMetadata.TMDB.Title) - year = meta.ExternalMetadata.TMDB.Year - } - if title == "" && meta.ExternalMetadata.IMDB != nil { - title = strings.TrimSpace(meta.ExternalMetadata.IMDB.Title) - year = meta.ExternalMetadata.IMDB.Year - } - if title == "" { - title = strings.TrimSpace(meta.Release.Title) - } - if year == 0 { - year = meta.Release.Year - } - if year == 0 { - return title, "" - } - return title, strconv.Itoa(year) -} - -func resolvePoster(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - if value := strings.TrimSpace(meta.ExternalMetadata.TMDB.Poster); value != "" { - return value - } - } - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Cover) - } - return "" -} - -func resolveOverview(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - if value := strings.TrimSpace(meta.ExternalMetadata.TMDB.Overview); value != "" { - return value - } - } - if meta.ExternalMetadata.IMDB != nil { - if value := strings.TrimSpace(meta.ExternalMetadata.IMDB.Plot); value != "" { - return value - } - } - return "" -} - -func resolveTrailer(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.YouTube) - } - return "" -} - -func resolveDirectors(meta api.PreparedMetadata) []string { - if meta.ExternalMetadata.TMDB != nil && len(meta.ExternalMetadata.TMDB.Directors) > 0 { - return append([]string{}, meta.ExternalMetadata.TMDB.Directors...) - } - if meta.ExternalMetadata.IMDB != nil && len(meta.ExternalMetadata.IMDB.Directors) > 0 { - names := make([]string, 0, len(meta.ExternalMetadata.IMDB.Directors)) - for _, person := range meta.ExternalMetadata.IMDB.Directors { - if strings.TrimSpace(person.Name) != "" { - names = append(names, strings.TrimSpace(person.Name)) - } - } - return names - } - return nil -} - -func resolveTags(meta api.PreparedMetadata) string { - values := make([]string, 0, 8) - if meta.ExternalMetadata.TMDB != nil { - for item := range strings.SplitSeq(meta.ExternalMetadata.TMDB.Genres, ",") { - trimmed := strings.ToLower(strings.TrimSpace(item)) - if trimmed != "" { - values = append(values, trimmed) - } - } - } - if len(values) == 0 && strings.TrimSpace(meta.Release.Genre) != "" { - for item := range strings.SplitSeq(meta.Release.Genre, ",") { - trimmed := strings.ToLower(strings.TrimSpace(item)) - if trimmed != "" { - values = append(values, trimmed) - } - } - } - if len(values) == 0 { - values = append(values, "action") - } - seen := make(map[string]struct{}, len(values)) - filtered := make([]string, 0, len(values)) - for _, value := range values { - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - filtered = append(filtered, value) - } - return strings.Join(filtered, ", ") -} - -func firstFile(meta api.PreparedMetadata) string { - if len(meta.FileList) > 0 { - return meta.FileList[0] - } - return meta.SourcePath -} - -func normalizedAnnounceURL(value string) string { - trimmed := strings.TrimSpace(value) - if strings.HasPrefix(trimmed, "http://please.passthepopcorn.me") { - return strings.Replace(trimmed, "http://", "https://", 1) - } - return trimmed -} - -func extractAlertError(body string) string { - start := strings.Index(body, `alert alert--error`) - if start == -1 { - return "" - } - segment := body[start:] - end := strings.Index(segment, "
") - if end != -1 { - segment = segment[:end] - } - return stripTags(segment) -} - -func stripTags(value string) string { - inTag := false - var builder strings.Builder - for _, r := range value { - switch r { - case '<': - inTag = true - case '>': - inTag = false - default: - if !inTag { - builder.WriteRune(r) - } - } - } - return strings.Join(strings.Fields(builder.String()), " ") -} - -func compactError(value string) string { - trimmed := strings.Join(strings.Fields(strings.TrimSpace(value)), " ") - if len(trimmed) > 220 { - return trimmed[:220] - } - return trimmed -} - -func stringFromAny(value any) string { - switch typed := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(typed) - case float64: - return strconv.Itoa(int(typed)) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - default: - return strings.TrimSpace(fmt.Sprint(value)) - } -} - -func joinInts(values []int) string { - if len(values) == 0 { - return "" - } - parts := make([]string, 0, len(values)) - for _, value := range values { - parts = append(parts, strconv.Itoa(value)) - } - return strings.Join(parts, ",") -} - -func readBDSummary(meta api.PreparedMetadata, dbPath string) (string, error) { - tmpRoot, err := db.Subdir(dbPath, "tmp") - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) - if err != nil { - return "", fmt.Errorf("trackers: %w", err) - } - return readTextFile(paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylist(meta))) -} - -func readTextFile(path string) (string, error) { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return "", nil - } - payload, err := os.ReadFile(trimmed) - if err != nil { - return "", fmt.Errorf("trackers: PTP read text file: %w", err) - } - return string(payload), nil -} - -func buildQuestionnaire(meta api.PreparedMetadata, groupID string) *api.TrackerQuestionnaire { - if strings.TrimSpace(groupID) != "" { - return nil - } - title, year := resolveGroupTitleYear(meta) - fields := []api.TrackerQuestionnaireField{ - {Key: "title", Label: "Group Title", Kind: "text", Value: title, Required: true}, - {Key: "year", Label: "Year", Kind: "text", Value: year, Required: false, Placeholder: "Release year"}, - {Key: "poster", Label: "Poster URL", Kind: "text", Value: resolvePoster(meta), Required: true}, - {Key: "tags", Label: "Tags", Kind: "text", Value: resolveTags(meta), Required: true, Placeholder: "Comma separated tags"}, - {Key: "trailer", Label: "Trailer URL", Kind: "text", Value: resolveTrailer(meta), Required: false, Placeholder: "YouTube trailer URL"}, - {Key: "album_desc", Label: "Group Description", Kind: "textarea", Value: resolveOverview(meta), Required: false}, - } - return &api.TrackerQuestionnaire{ - Tracker: "PTP", - Fields: fields, - } -} - -func questionnaireAnswers(meta api.PreparedMetadata, tracker string) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers[strings.ToUpper(strings.TrimSpace(tracker))] -} - -var subtitleIDs = map[string]int{ - "arabic": 22, - "brazilian portuguese": 49, - "bulgarian": 29, - "chinese": 14, - "croatian": 23, - "czech": 30, - "danish": 10, - "dutch": 9, - "english": 3, - "english - forced": 50, - "english forced": 50, - "english intertitles": 51, - "estonian": 38, - "finnish": 15, - "french": 5, - "german": 6, - "greek": 26, - "hebrew": 40, - "hindi": 41, - "hungarian": 24, - "icelandic": 28, - "indonesian": 47, - "italian": 16, - "japanese": 8, - "korean": 19, - "latvian": 37, - "lithuanian": 39, - "norwegian": 12, - "polish": 17, - "portuguese": 21, - "romanian": 13, - "russian": 7, - "serbian": 31, - "slovak": 42, - "slovenian": 43, - "spanish": 4, - "swedish": 11, - "thai": 20, - "turkish": 18, - "ukrainian": 34, - "vietnamese": 25, -} diff --git a/internal/trackers/impl/ptp/upload_test.go b/internal/trackers/impl/ptp/upload_test.go deleted file mode 100644 index 6d9a7b76..00000000 --- a/internal/trackers/impl/ptp/upload_test.go +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ptp - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/authmaterial" - "github.com/autobrr/upbrr/internal/config" - cookiepkg "github.com/autobrr/upbrr/internal/cookies" - servicedb "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestLoadCookiesSuccessReturnsNilError(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "abc"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - - got, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if got["session"] != "abc" { - t.Fatalf("unexpected cookies: %#v", got) - } -} - -func TestResolveSessionForTrackerAuthPreservesCookiesOnTransientTokenFetch(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "abc"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - baseURL := server.URL - server.Close() - - err := ResolveSessionForTrackerAuth(ctx, config.TrackerConfig{ - URL: baseURL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath) - if err == nil { - t.Fatal("expected transient token fetch error") - } - got, loadErr := loadCookies(ctx, dbPath) - if loadErr != nil { - t.Fatalf("loadCookies after transient error: %v", loadErr) - } - if got["session"] != "abc" { - t.Fatalf("expected transient failure to preserve cookies, got %#v", got) - } -} - -func TestLoginAndFetchAntiCsrfTokenReturnsPersistenceError(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) - })) - t.Cleanup(server.Close) - - _, _, err := resolveSession(context.Background(), config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, filepath.Join(t.TempDir(), "upbrr.db"), server.URL, api.NopLogger{}) - if err == nil { - t.Fatal("expected persistence error") - } - if !strings.Contains(err.Error(), "persist login cookies") { - t.Fatalf("expected persistence error, got %v", err) - } -} - -func TestLoginAndFetchAntiCsrfTokenDoesNotOverwriteCookiesWhenTokenMissing(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == ptpUploadPath { - _, _ = w.Write([]byte("logged out")) - return - } - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`{"Result":"Ok"}`)) - })) - t.Cleanup(server.Close) - - _, _, err := resolveSession(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, server.URL, api.NopLogger{}) - if err == nil { - t.Fatal("expected missing token error") - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "existing" { - t.Fatalf("missing token must not overwrite stored cookies, got %#v", values) - } -} - -func TestLoginAndFetchAntiCsrfTokenPersistsCookiesAfterTokenGate(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) - })) - t.Cleanup(server.Close) - - _, token, err := resolveSession(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, server.URL, api.NopLogger{}) - if err != nil { - t.Fatalf("resolveSession: %v", err) - } - if token != "token" { - t.Fatalf("unexpected token %q", token) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "new" { - t.Fatalf("expected saved login cookies, got %#v", values) - } -} - -func TestLoginAndFetchAntiCsrfTokenRejectsEmptyJarWithoutReplacingCookies(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == ptpUploadPath: - _, _ = w.Write([]byte("logged out")) - case r.URL.Path == "/ajax.php" && r.URL.RawQuery == "action=login": - _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - _, _, err := resolveSession(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, server.URL, api.NopLogger{}) - if err == nil || !strings.Contains(err.Error(), "no usable cookies") { - t.Fatalf("expected empty cookie jar error, got %v", err) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "existing" { - t.Fatalf("empty login jar must preserve stored cookies, got %#v", values) - } -} - -func TestResolveSessionForTrackerAuthLoginUsesManual2FACode(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - var gotCode string - loginRequests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - if err := r.ParseForm(); err != nil { - t.Errorf("ParseForm: %v", err) - return - } - loginRequests++ - if loginRequests == 1 { - _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) - return - } - gotCode = r.FormValue("TfaCode") - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, api.TrackerAuthLoginRequest{Code: "654321"}) - if err != nil { - t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) - } - if gotCode != "654321" { - t.Fatalf("expected manual 2FA code, got %q", gotCode) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "new" { - t.Fatalf("expected saved 2FA login cookies, got %#v", values) - } -} - -func TestResolveSessionForTrackerAuthLoginMarksSubmitted2FARejected(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - loginRequests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - loginRequests++ - if loginRequests == 1 { - _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) - return - } - _, _ = w.Write([]byte(`{"Result":"Invalid"}`)) - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}) - if !errors.Is(err, ErrSubmitted2FARejected) { - t.Fatalf("expected submitted 2FA rejection marker, got %v", err) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "existing" { - t.Fatalf("submitted 2FA rejection must preserve stored cookies, got %#v", values) - } -} - -func TestResolveSessionForTrackerAuthLoginPreCodeFailureIsNotSubmitted2FARejected(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { - http.NotFound(w, r) - return - } - _, _ = w.Write([]byte(`{"Result":"Invalid"}`)) - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}) - if err == nil || !strings.Contains(err.Error(), "login failed") { - t.Fatalf("expected pre-code login failed error, got %v", err) - } - if errors.Is(err, ErrSubmitted2FARejected) { - t.Fatalf("pre-code login failure must not carry submitted 2FA marker: %v", err) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "existing" { - t.Fatalf("pre-code login failure must preserve stored cookies, got %#v", values) - } -} - -func TestResolveSessionForTrackerAuthLoginMissing2FACodePreservesCookies(t *testing.T) { - t.Parallel() - - ctx := context.Background() - dbPath := newPTPAuthDB(t) - if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { - t.Fatalf("SaveTrackerCookieMap: %v", err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == ptpUploadPath: - _, _ = w.Write([]byte("logged out")) - case r.URL.Path == "/ajax.php" && r.URL.RawQuery == "action=login": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) - _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", - }, dbPath, api.TrackerAuthLoginRequest{}) - if err == nil || !strings.Contains(err.Error(), "2FA required") { - t.Fatalf("expected missing 2FA error, got %v", err) - } - values, err := loadCookies(ctx, dbPath) - if err != nil { - t.Fatalf("loadCookies: %v", err) - } - if values["session"] != "existing" { - t.Fatalf("missing 2FA code must preserve stored cookies, got %#v", values) - } -} - -func newPTPAuthDB(t *testing.T) string { - t.Helper() - - ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "upbrr.db") - if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { - t.Fatalf("BootstrapAuthFile: %v", err) - } - repo, err := servicedb.OpenWithLoggerContext(ctx, dbPath, api.NopLogger{}) - if err != nil { - t.Fatalf("OpenWithLoggerContext: %v", err) - } - if err := repo.MigrateContext(ctx); err != nil { - _ = repo.Close() - t.Fatalf("MigrateContext: %v", err) - } - _ = repo.Close() - return dbPath -} diff --git a/internal/trackers/impl/ptp/wrap_errors.go b/internal/trackers/impl/ptp/wrap_errors.go deleted file mode 100644 index f27a5764..00000000 --- a/internal/trackers/impl/ptp/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package ptp - -import "fmt" - -func wrapTrackerError(err error) error { - if err != nil { - return fmt.Errorf("trackers: %w", err) - } - return nil -} diff --git a/internal/trackers/impl/pts/definition.go b/internal/trackers/impl/pts/definition.go deleted file mode 100644 index 52bade36..00000000 --- a/internal/trackers/impl/pts/definition.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package pts - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } - -func (definition) Name() string { return "PTS" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, assets) - return trackers.DescriptionResult{Group: "pts", Description: description}, nil -} diff --git a/internal/trackers/impl/pts/upload.go b/internal/trackers/impl/pts/upload.go deleted file mode 100644 index f122dd1e..00000000 --- a/internal/trackers/impl/pts/upload.go +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package pts - -import ( - "bytes" - "context" - "fmt" - "maps" - "net/http" - "net/url" - "regexp" - "strings" - - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://www.ptskit.org" - uploadURL = baseURL + "/takeupload.php" - sourceFlag = "[www.ptskit.org] PTSKIT" -) - -var idPattern = regexp.MustCompile(`download\.php\?id=([^&]+)`) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - questionnaire *api.TrackerQuestionnaire - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, cookies, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: PTS %s", state.blockedReason) - } - - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ - FieldName: "file", - FileName: "PTS.torrent", - Path: state.torrentPath, - }}) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTS request build: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - commonhttp.ApplyCookies(httpReq, cookies) - - resp, err := (&http.Client{ - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, - }).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTS upload request: %w", err) - } - defer resp.Body.Close() - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: PTS read upload response: %w", err) - } - - location := strings.TrimSpace(resp.Header.Get("Location")) - torrentID := parseUploadID(location, string(responseBody)) - if (resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther) && torrentID != "" { - tURL := baseURL + "/details.php?id=" + url.QueryEscape(torrentID) - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "PTS") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "PTS", - TorrentID: torrentID, - TorrentURL: tURL, - DownloadURL: baseURL + "/download.php?id=" + url.QueryEscape(torrentID), - TorrentPath: artifactPath, - }}, - }, nil - } - - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "PTS", "upload_failure", responsePreview, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("PTS", resp.StatusCode, responsePreview) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "PTS", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "pts", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, []*http.Cookie, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, assets) - - state := uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), - fields: buildPayload(req.Meta, description), - questionnaire: buildQuestionnaire(req.Meta), - blockedReason: validateUpload(req.Meta), - } - cookies, err := loadCookies(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: PTS load cookies: %w", err) - } - return state, cookies, nil -} - -func buildPayload(meta api.PreparedMetadata, description string) map[string]string { - return map[string]string{ - "name": metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename), - "url": imdbURL(meta), - "descr": description, - "type": resolveType(meta), - } -} - -func buildDescription(meta api.PreparedMetadata, assets trackers.DescriptionAssets) string { - parts := make([]string, 0, 4) - if info := commonhttp.ReadOptionalFile(strings.TrimSpace(meta.MediaInfoTextPath)); strings.TrimSpace(info) != "" { - parts = append(parts, info) - } - if base := strings.TrimSpace(assets.Description); base != "" { - parts = append(parts, sanitizeDescription(base)) - } - if shots := screenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - parts = append(parts, "[right][url=https://github.com/autobrr/upbrr][size=1]upbrr[/size][/url][/right]") - return bbcode.FinalizeTrackerDescription("PTS", strings.TrimSpace(strings.Join(parts, "\n\n"))) -} - -func buildQuestionnaire(meta api.PreparedMetadata) *api.TrackerQuestionnaire { - if hasMandarin(meta) { - return nil - } - answer := strings.ToLower(strings.TrimSpace(questionnaireAnswers(meta)["mandarin_override"])) - return &api.TrackerQuestionnaire{ - Tracker: "PTS", - Fields: []api.TrackerQuestionnaireField{{ - Key: "mandarin_override", - Label: "Mandarin Requirement", - Kind: "select", - Options: []string{"no", "yes"}, - Value: metautil.FirstNonEmptyTrimmed(answer, "no"), - Help: "PTS expects Mandarin audio or subtitles. Choose yes to override and upload anyway.", - Required: true, - }}, - } -} - -func validateUpload(meta api.PreparedMetadata) string { - if hasMandarin(meta) { - return "" - } - if strings.EqualFold(strings.TrimSpace(questionnaireAnswers(meta)["mandarin_override"]), "yes") { - return "" - } - return "missing Mandarin audio/subtitles; answer the override questionnaire to continue" -} - -func hasMandarin(meta api.PreparedMetadata) bool { - for _, values := range [][]string{meta.AudioLanguages, meta.SubtitleLanguages} { - for _, value := range values { - lower := strings.ToLower(strings.TrimSpace(value)) - if strings.Contains(lower, "mandarin") || strings.Contains(lower, "chinese") { - return true - } - } - } - return false -} - -func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { - return wrapTrackerResult(cookies.LoadTrackerHTTPCookies(ctx, dbPath, "PTS", "ptskit.org")) -} - -func resolveType(meta api.PreparedMetadata) string { - if meta.Anime { - return "407" - } - if isTV(meta) { - return "405" - } - return "404" -} - -func sanitizeDescription(input string) string { - return bbcode.FinalizeTrackerDescription("PTS", input) -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - lines := []string{"[center][b]Screenshots[/b]"} - for _, image := range images { - imgURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL)) - webURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.WebURL, imgURL)) - if imgURL == "" || webURL == "" { - continue - } - lines = append(lines, fmt.Sprintf("[url=%s][img]%s[/img][/url]", webURL, imgURL)) - } - lines = append(lines, "[/center]") - return strings.Join(lines, "\n") -} - -func parseUploadID(location string, body string) string { - for _, value := range []string{location, body} { - match := idPattern.FindStringSubmatch(value) - if len(match) == 2 { - return strings.TrimSpace(match[1]) - } - } - return "" -} - -func imdbURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["PTS"] -} - -func cloneFields(input map[string]string) map[string]string { - out := make(map[string]string, len(input)) - maps.Copy(out, input) - return out -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} diff --git a/internal/trackers/impl/pts/wrap_errors.go b/internal/trackers/impl/pts/wrap_errors.go deleted file mode 100644 index 99a82eb1..00000000 --- a/internal/trackers/impl/pts/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package pts - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/registry.go b/internal/trackers/impl/registry.go index da4a4240..54bccede 100644 --- a/internal/trackers/impl/registry.go +++ b/internal/trackers/impl/registry.go @@ -7,123 +7,125 @@ import ( "fmt" "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/ant" - "github.com/autobrr/upbrr/internal/trackers/impl/ar" - "github.com/autobrr/upbrr/internal/trackers/impl/asc" "github.com/autobrr/upbrr/internal/trackers/impl/azfamily" - "github.com/autobrr/upbrr/internal/trackers/impl/bhd" - "github.com/autobrr/upbrr/internal/trackers/impl/bhdtv" - "github.com/autobrr/upbrr/internal/trackers/impl/bjs" - "github.com/autobrr/upbrr/internal/trackers/impl/bt" - "github.com/autobrr/upbrr/internal/trackers/impl/btn" - "github.com/autobrr/upbrr/internal/trackers/impl/czt" - "github.com/autobrr/upbrr/internal/trackers/impl/dc" - "github.com/autobrr/upbrr/internal/trackers/impl/ff" - "github.com/autobrr/upbrr/internal/trackers/impl/fl" - "github.com/autobrr/upbrr/internal/trackers/impl/gpw" - "github.com/autobrr/upbrr/internal/trackers/impl/hdb" - "github.com/autobrr/upbrr/internal/trackers/impl/hds" - "github.com/autobrr/upbrr/internal/trackers/impl/hdt" - "github.com/autobrr/upbrr/internal/trackers/impl/is" - "github.com/autobrr/upbrr/internal/trackers/impl/mtv" - "github.com/autobrr/upbrr/internal/trackers/impl/nbl" - "github.com/autobrr/upbrr/internal/trackers/impl/ptp" - "github.com/autobrr/upbrr/internal/trackers/impl/pts" - "github.com/autobrr/upbrr/internal/trackers/impl/rtf" - "github.com/autobrr/upbrr/internal/trackers/impl/spd" - "github.com/autobrr/upbrr/internal/trackers/impl/thr" - "github.com/autobrr/upbrr/internal/trackers/impl/tl" - "github.com/autobrr/upbrr/internal/trackers/impl/tvc" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ant" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ar" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/asc" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bhd" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bhdtv" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bjs" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/bt" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/btn" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/czt" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/dc" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ff" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/fl" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/gpw" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/hdb" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/hds" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/hdt" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/is" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/mtv" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/nbl" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/ptp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/pts" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/rtf" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/spd" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/thr" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/tl" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/tvc" "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/a4k" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/acm" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/aither" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/blu" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/cbr" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/dp" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/emuw" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/friki" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/hhd" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ihd" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/itt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/lcd" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ldu" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/lst" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/lt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/lume" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/mns" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/oe" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/otw" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/pt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ptt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/r4e" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ras" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/rf" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/rhd" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/sam" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/shri" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/sp" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/stc" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/tik" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/tlz" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/tos" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ttr" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ulcx" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/utp" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/yus" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/znth" ) +// NewRegistry composes the complete built-in Unit3D, AZ-family, and standalone +// manifest, then installs the metadata-source priority order. It returns an +// error if any compiled definition violates registry invariants. func NewRegistry() (*trackers.Registry, error) { registry := trackers.NewRegistry() - if err := unit3d.Register(registry, unit3d.DefaultTrackers()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(hdb.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(mtv.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(ant.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(ar.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(asc.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(bhd.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(bhdtv.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(bjs.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(btn.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(bt.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(czt.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(dc.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(ff.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(fl.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(gpw.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(hds.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(hdt.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(is.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(nbl.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(ptp.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(pts.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(rtf.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(spd.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(thr.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(tl.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - if err := registry.Register(tvc.New()); err != nil { - return nil, fmt.Errorf("trackers: %w", err) - } - for _, name := range []string{"AZ", "CZ", "PHD"} { - if err := registry.Register(azfamily.New(name)); err != nil { + for _, definition := range builtInDefinitions() { + if err := registry.Register(definition); err != nil { return nil, fmt.Errorf("trackers: %w", err) } } + registry.SetPriorityOrder([]string{"aither", "ulcx", "lst", "blu", "oe", "btn", "bhd", "hdb", "ant", "rf", "otw", "yus", "dp", "sp", "ptp"}) return registry, nil } + +// MustNewRegistry returns the built-in tracker registry or panics when the +// compiled manifest violates registry invariants. +func MustNewRegistry() *trackers.Registry { + registry, err := NewRegistry() + if err != nil { + panic(fmt.Sprintf("trackers: invalid built-in registry: %v", err)) + } + return registry +} + +func unit3DDefinitions() []trackers.Definition { + profiles := []unit3d.Profile{ + a4k.Profile(), acm.Profile(), aither.Profile(), blu.Profile(), cbr.Profile(), dp.Profile(), emuw.Profile(), friki.Profile(), hhd.Profile(), + ihd.Profile(), itt.Profile(), lcd.Profile(), ldu.Profile(), lst.Profile(), lt.Profile(), lume.Profile(), mns.Profile(), oe.Profile(), otw.Profile(), + pt.Profile(), ptt.Profile(), r4e.Profile(), ras.Profile(), rf.Profile(), rhd.Profile(), sam.Profile(), shri.Profile(), sp.Profile(), stc.Profile(), + tik.Profile(), tlz.Profile(), tos.Profile(), ttr.Profile(), ulcx.Profile(), utp.Profile(), yus.Profile(), znth.Profile(), + } + definitions := make([]trackers.Definition, 0, len(profiles)) + for _, profile := range profiles { + definitions = append(definitions, unit3d.NewWithProfile(profile)) + } + return definitions +} + +func azFamilyDefinitions() []trackers.Definition { + return []trackers.Definition{azfamily.New("AZ"), azfamily.New("CZ"), azfamily.New("PHD")} +} + +func standaloneDefinitions() []trackers.Definition { + return []trackers.Definition{ + hdb.New(), mtv.New(), ant.New(), ar.New(), asc.New(), bhd.New(), bhdtv.New(), bjs.New(), btn.New(), bt.New(), czt.New(), dc.New(), ff.New(), + fl.New(), gpw.New(), hds.New(), hdt.New(), is.New(), nbl.New(), ptp.New(), pts.New(), rtf.New(), spd.New(), thr.New(), tl.New(), tvc.New(), + } +} + +func builtInDefinitions() []trackers.Definition { + definitions := unit3DDefinitions() + definitions = append(definitions, azFamilyDefinitions()...) + definitions = append(definitions, standaloneDefinitions()...) + return definitions +} diff --git a/internal/trackers/impl/registry_test.go b/internal/trackers/impl/registry_test.go index 8c3dab76..a051d6a8 100644 --- a/internal/trackers/impl/registry_test.go +++ b/internal/trackers/impl/registry_test.go @@ -3,7 +3,16 @@ package impl -import "testing" +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" +) func TestNewRegistryIncludesHDB(t *testing.T) { registry, err := NewRegistry() @@ -92,3 +101,383 @@ func TestNewRegistryIncludesHDB(t *testing.T) { t.Fatal("expected PHD definition to be registered") } } + +func TestNewRegistryCapabilityInventory(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + names := registry.Names() + if !slices.IsSorted(names) { + t.Fatalf("registry names are not deterministic: %v", names) + } + schemas, err := config.OrderedTrackerSchemas() + if err != nil { + t.Fatalf("ordered tracker schemas: %v", err) + } + want := make([]string, 0, len(schemas)) + for _, schema := range schemas { + want = append(want, schema.Name) + } + slices.Sort(want) + if !slices.Equal(names, want) { + t.Fatalf("registered trackers = %v, want %v", names, want) + } + if slices.Contains(names, "MANUAL") { + t.Fatal("MANUAL must not be registered") + } + specialModes := map[string]trackers.UploadContentMode{ + "ANT": trackers.UploadContentModeScreenshots, + "BTN": trackers.UploadContentModeNone, + "NBL": trackers.UploadContentModeNone, + "RTF": trackers.UploadContentModeScreenshots, + } + for _, name := range names { + mode, ok := registry.LookupUploadContentMode(name) + if !ok || !mode.Valid() { + t.Fatalf("%s upload content mode = %q, %t", name, mode, ok) + } + want := trackers.UploadContentModeDescription + if special, exists := specialModes[name]; exists { + want = special + } + if mode != want { + t.Fatalf("%s upload content mode = %q, want %q", name, mode, want) + } + } + if definition, ok := registry.Lookup("BHDTV"); !ok { + t.Fatal("expected BHDTV definition") + } else if _, ok := definition.(dupe.Factory); !ok { + t.Fatal("expected BHDTV tracker-owned dupe capability") + } + if definition, ok := registry.Lookup("ANT"); !ok { + t.Fatal("expected ANT definition") + } else if _, ok := definition.(dupe.Factory); !ok { + t.Fatal("expected ANT tracker-owned dupe factory") + } + if _, ok := registry.LookupDataFactory("ANT"); !ok { + t.Fatal("expected ANT tracker-owned data factory") + } + if policy, ok := registry.LookupArtifactPolicy("ANT"); !ok || policy.MaxTorrentBytes != 250<<10 { + t.Fatalf("ANT artifact policy = %#v, %t", policy, ok) + } + if _, ok := registry.LookupRules("ANT"); !ok { + t.Fatal("expected ANT tracker-owned rules") + } + if groups, ok := registry.LookupBannedGroups("ANT"); !ok || !slices.Contains(groups, "ZMNT") { + t.Fatalf("ANT banned groups = %#v, %t", groups, ok) + } + if policy, ok := registry.LookupUploadArtifactPolicy("ANT"); !ok || policy.Source != "ANT" { + t.Fatalf("ANT upload artifact policy = %#v, %t", policy, ok) + } + if _, ok := registry.LookupMetadataPolicy("ANT"); !ok { + t.Fatal("expected ANT tracker-owned metadata policy") + } + if policy, ok := registry.LookupDupePolicy("ANT"); !ok || !policy.DolbyVisionImpliesHDR { + t.Fatalf("ANT dupe policy = %#v, %t", policy, ok) + } +} + +func TestNewRegistryOwnsUploadArtifactPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + want := map[string]trackers.UploadArtifactPolicy{ + "ACM": {Source: "AsianCinema"}, + "AR": {Source: "AlphaRatio"}, + "ASC": {Source: "ASC"}, + "AZ": {Source: "AvistaZ", DefaultAnnounce: "https://tracker.avistaz.to/announce"}, + "BHDTV": {Source: "BIT-HDTV", UseMyAnnounce: true}, + "BJS": {Source: "BJ"}, + "BT": {Source: "BT"}, + "CZ": {Source: "CinemaZ", DefaultAnnounce: "https://tracker.cinemaz.to/announce"}, + "CZT": {Source: "CzT"}, + "DC": {Source: "DigitalCore.club"}, + "FF": {Source: "FunFile"}, + "FL": {Source: "FL"}, + "GPW": {Source: "GreatPosterWall"}, + "HDS": {Source: "HD-Space"}, + "HDT": {Source: "hd-torrents.org"}, + "IS": {Source: "https://immortalseed.me"}, + "MTV": {Source: "MTV"}, + "NBL": {Source: "NBL"}, + "PHD": {Source: "PrivateHD", DefaultAnnounce: "https://tracker.privatehd.to/announce"}, + "PTS": {Source: "[www.ptskit.org] PTSKIT"}, + "RTF": {Source: "sunshine"}, + "THR": {Source: "[https://www.torrenthr.org] TorrentHR.org"}, + "TL": {Source: "TorrentLeech.org"}, + "TOS": {Source: "TheOldSchool"}, + "TVC": {Source: "TVCHAOS"}, + } + for name, expected := range want { + got, ok := registry.LookupUploadArtifactPolicy(name) + if !ok || got != expected { + t.Errorf("%s upload artifact policy = %#v, %t; want %#v", name, got, ok, expected) + } + } +} + +func TestNewRegistryOwnsMetadataPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + for _, name := range []string{"AR", "AZ", "BJS", "CZ", "CZT", "MTV", "NBL", "PHD", "PTP", "SPD", "THR", "TL", "TVC", "AITHER"} { + if _, ok := registry.LookupMetadataPolicy(name); !ok { + t.Errorf("expected %s tracker-owned metadata policy", name) + } + } + + mtvPolicy, ok := registry.LookupMetadataPolicy("MTV") + if !ok || len(mtvPolicy.Requirements) != 1 { + t.Fatalf("MTV metadata policy = %#v, %t; want one requirement", mtvPolicy, ok) + } + mtvRequirement := mtvPolicy.Requirements[0] + wantFields := []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB, trackers.MetadataFieldTVDB} + if mtvRequirement.Scope != trackers.MetadataScopeAny || !slices.Equal(mtvRequirement.AnyOf, wantFields) { + t.Errorf("MTV metadata requirement = %#v; want any of %#v", mtvRequirement, wantFields) + } +} + +func TestNewRegistryIncludesUnit3DRuleCapabilities(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + + trackersWithRules := []string{ + "A4K", "AITHER", "BLU", "DP", "HHD", "LST", "LUME", "MNS", "OE", "OTW", "RAS", + "RF", "RHD", "SHRI", "SP", "STC", "TIK", "TOS", "TTR", "ULCX", "ZNTH", + } + for _, name := range trackersWithRules { + if _, ok := registry.LookupRules(name); !ok { + t.Errorf("expected %s tracker-owned rule capability", name) + } + } + if baseURL, ok := registry.LookupBaseURL("AITHER"); !ok || baseURL != "https://aither.cc" { + t.Fatalf("AITHER base URL = %q, %t", baseURL, ok) + } +} + +func TestNewRegistryIncludesBHDPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if _, ok := registry.LookupRules("BHD"); !ok { + t.Fatal("expected BHD rules") + } + if _, ok := registry.LookupMetadataPolicy("BHD"); !ok { + t.Fatal("expected BHD metadata policy") + } + if policy, ok := registry.LookupUploadArtifactPolicy("BHD"); !ok || policy.Source != "BHD" { + t.Fatalf("BHD upload artifact policy = %#v, %t", policy, ok) + } + if policy, ok := registry.LookupAudioPolicy("BHD"); !ok || !policy.BlockEnglishOriginalWithForeign { + t.Fatalf("BHD audio policy = %#v, %t", policy, ok) + } + if groups, ok := registry.LookupBannedGroups("BHD"); !ok || !slices.Contains(groups, "TGS") { + t.Fatalf("BHD banned groups = %#v, %t", groups, ok) + } + if policy, ok := registry.LookupDupePolicy("BHD"); !ok || !policy.MatchAggregateSize || !policy.NormalizeDDPlusName { + t.Fatalf("BHD dupe policy = %#v, %t", policy, ok) + } + if definition, ok := registry.Lookup("BHD"); !ok { + t.Fatal("expected BHD definition") + } else if _, ok := definition.(dupe.Factory); !ok { + t.Fatal("expected BHD dupe factory") + } + if _, ok := registry.LookupDataFactory("BHD"); !ok { + t.Fatal("expected BHD data factory") + } +} + +func TestNewRegistryIncludesBTNPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if _, ok := registry.LookupMetadataPolicy("BTN"); !ok { + t.Fatal("expected BTN metadata policy") + } + if policy, ok := registry.LookupUploadArtifactPolicy("BTN"); !ok || policy.Source != "BTN" || !policy.RequireAnnounce { + t.Fatalf("BTN upload artifact policy = %#v, %t", policy, ok) + } + if groups, ok := registry.LookupBannedGroups("BTN"); !ok || !slices.Contains(groups, "ZMNT") { + t.Fatalf("BTN banned groups = %#v, %t", groups, ok) + } + if definition, ok := registry.Lookup("BTN"); !ok { + t.Fatal("expected BTN definition") + } else if _, ok := definition.(dupe.Factory); !ok { + t.Fatal("expected BTN dupe factory") + } + if _, ok := registry.LookupDataFactory("BTN"); !ok { + t.Fatal("expected BTN data factory") + } + if _, ok := registry.LookupClaimCheckerFactory("BTN"); !ok { + t.Fatal("expected BTN claim checker factory") + } + btnConfig := config.Config{Metadata: config.MetadataConfig{BTNAPI: strings.Repeat("x", 25)}} + if ready, owned := registry.DataLookupConfigured("BTN", btnConfig); !owned || !ready { + t.Fatalf("BTN lookup readiness = %t, %t", ready, owned) + } +} + +func TestNewRegistryIncludesHDBPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if _, ok := registry.LookupMetadataPolicy("HDB"); !ok { + t.Fatal("expected HDB metadata policy") + } + if policy, ok := registry.LookupUploadArtifactPolicy("HDB"); !ok || policy.Source != "HDBits" { + t.Fatalf("HDB upload artifact policy = %#v, %t", policy, ok) + } + if policy, ok := registry.LookupArtifactPolicy("HDB"); !ok || policy.MaxPieceSizeMiB != 16 { + t.Fatalf("HDB artifact policy = %#v, %t", policy, ok) + } + if _, ok := registry.LookupDataFactory("HDB"); !ok { + t.Fatal("expected HDB data factory") + } + cfg := config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"HDB": {Username: "user", Passkey: "pass"}}}} + if ready, owned := registry.DataLookupConfigured("HDB", cfg); !owned || !ready { + t.Fatalf("HDB lookup readiness = %t, %t", ready, owned) + } +} + +func TestNewRegistryIncludesPTPDataPolicy(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + policy, ok := registry.LookupDataPolicy("PTP") + if !ok || policy.Cooldown != time.Minute { + t.Fatalf("PTP data policy = %#v, %t", policy, ok) + } +} + +func TestNewRegistryIncludesImageHostPolicies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + tests := []struct { + tracker string + host string + conditionalHost string + disableWithoutRehost bool + disableWithoutAPI bool + }{ + {tracker: "A4K", host: "onlyimage"}, + {tracker: "BHD", host: "bhd"}, + { + tracker: "HDB", + host: "hdb", + disableWithoutRehost: true, + }, + {tracker: "PTP", host: "passtheimage"}, + { + tracker: "THR", + host: "thr", + disableWithoutAPI: true, + }, + {tracker: "LST", conditionalHost: "lostimg"}, + {tracker: "RF", conditionalHost: "reelflix"}, + } + for _, test := range tests { + policy, ok := registry.LookupImageHostPolicy(test.tracker) + if !ok || (test.host != "" && !slices.Contains(policy.AllowedHosts, test.host)) { + t.Errorf("%s image policy = %#v, %t", test.tracker, policy, ok) + continue + } + if policy.DisableWithoutRehost != test.disableWithoutRehost || policy.DisableWithoutAPI != test.disableWithoutAPI { + t.Errorf("%s image policy flags = %#v", test.tracker, policy) + } + if policy.ConditionalHost != test.conditionalHost { + t.Errorf("%s conditional image host = %q", test.tracker, policy.ConditionalHost) + } + } +} + +func TestNewRegistryIncludesAuthResolvers(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + for _, tracker := range []string{"BTN", "FF", "FL", "HDB", "MTV", "PTP", "RTF"} { + if _, ok := registry.LookupAuthSessionResolver(tracker); !ok { + t.Errorf("expected %s tracker-owned auth resolver", tracker) + } + capability, ok := registry.LookupAuthCapability(tracker) + if !ok || capability.TrackerID != tracker { + t.Errorf("%s auth capability = %#v, %t", tracker, capability, ok) + } + if tracker != "HDB" && !capability.SupportsLogin { + t.Errorf("expected %s login capability", tracker) + } + } +} + +func TestNewRegistryIncludesAuthCapabilities(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + trackersWithAuth := []string{"AR", "ASC", "AZ", "BJS", "BT", "BTN", "CZ", "FF", "FL", "HDB", "HDS", "HDT", "IS", "MTV", "PHD", "PTP", "PTS", "RTF", "THR", "TL"} + for _, tracker := range trackersWithAuth { + capability, ok := registry.LookupAuthCapability(tracker) + if !ok || capability.TrackerID != tracker { + t.Errorf("%s auth capability = %#v, %t", tracker, capability, ok) + } + } +} + +func TestNewRegistryClassifiesTrackerFamilies(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if family, ok := registry.LookupFamily("AITHER"); !ok || family != trackers.FamilyUnit3D { + t.Fatalf("AITHER family = %q, %t", family, ok) + } + if family, ok := registry.LookupFamily("AZ"); !ok || family != trackers.FamilyAZFamily { + t.Fatalf("AZ family = %q, %t", family, ok) + } + if family, ok := registry.LookupFamily("PTP"); !ok || family != trackers.FamilyStandalone { + t.Fatalf("PTP family = %q, %t", family, ok) + } + if !slices.Contains(registry.NamesByFamily(trackers.FamilyUnit3D), "AITHER") { + t.Fatal("expected AITHER in Unit3D registry names") + } +} + +func TestNewRegistryDeclaresLocalizedMetadataConsumers(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if !registry.NeedsLocalizedMetadata([]string{"ASC", "BJS", "BT"}, "pt-BR") { + t.Fatal("expected pt-BR localized metadata consumers") + } + if registry.NeedsLocalizedMetadata([]string{"PTP"}, "pt-BR") { + t.Fatal("did not expect PTP to consume pt-BR metadata") + } +} + +func TestNewRegistryDeclaresDescriptionGroups(t *testing.T) { + registry, err := NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + if got := trackers.DescriptionOverrideGroupForTrackerWithRegistry("ACM", registry); got != "acm" { + t.Fatalf("ACM description group = %q", got) + } + if got := trackers.DescriptionOverrideGroupForTrackerWithRegistry("AITHER", registry); got != "unit3d" { + t.Fatalf("AITHER description group = %q", got) + } + if policy, ok := registry.LookupClaimPolicy("AITHER"); !ok || !policy.APIBacked { + t.Fatalf("AITHER claim policy = %#v, %t", policy, ok) + } +} diff --git a/internal/trackers/impl/rtf/definition.go b/internal/trackers/impl/rtf/definition.go deleted file mode 100644 index d6f5602d..00000000 --- a/internal/trackers/impl/rtf/definition.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package rtf - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "RTF" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - var ( - err error - assets trackers.DescriptionAssets - ) - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - } - description := buildDescription(assets) - return trackers.DescriptionResult{Group: "rtf", Description: description}, nil -} diff --git a/internal/trackers/impl/rtf/upload.go b/internal/trackers/impl/rtf/upload.go deleted file mode 100644 index de704e54..00000000 --- a/internal/trackers/impl/rtf/upload.go +++ /dev/null @@ -1,482 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package rtf - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/metadata/metautil" - servicedb "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - defaultBaseURL = "https://retroflix.club" - sourceFlag = "sunshine" -) - -var newHTTPClient = func() *http.Client { return &http.Client{Timeout: 40 * time.Second} } - -type uploadResponse struct { - Error bool `json:"error"` - Message string `json:"message"` - Torrent struct { - ID any `json:"id"` - } `json:"torrent"` -} - -type uploadState struct { - torrentPath string - releaseName string - description string - payload map[string]any - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - baseURL := resolveBaseURL(req.TrackerConfig) - uploadURL, err := joinURL(baseURL, "/api/upload") - if err != nil { - return api.UploadSummary{}, err - } - - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: RTF %s", state.blockedReason) - } - - apiKey, err := resolveAPIKey(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - - body, err := json.Marshal(state.payload) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: RTF marshal upload payload: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, strings.NewReader(string(body))) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: RTF create upload request: %w", err) - } - httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", apiKey) - - resp, err := newHTTPClient().Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: RTF upload request: %w", err) - } - defer resp.Body.Close() - responseBody, _ := io.ReadAll(resp.Body) - - var decoded uploadResponse - _ = json.Unmarshal(responseBody, &decoded) - if resp.StatusCode == http.StatusCreated && !decoded.Error { - id := strings.TrimSpace(fmt.Sprint(decoded.Torrent.ID)) - if id == "" { - return api.UploadSummary{}, errors.New("trackers: RTF upload succeeded but torrent id missing") - } - torrentURL, err := joinURL(baseURL, "/browse/t/") - if err != nil { - return api.UploadSummary{}, err - } - downloadURL, err := joinURL(baseURL, "/api/torrent/") - if err != nil { - return api.UploadSummary{}, err - } - tURL := torrentURL + id - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "RTF") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, tURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "RTF", - TorrentID: id, - TorrentURL: tURL, - DownloadURL: downloadURL + id + "/download", - TorrentPath: artifactPath, - }}, - }, nil - } - - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "RTF", "upload_failure", responseBody, ".json") - return api.UploadSummary{}, fmt.Errorf("trackers: RTF %s", metautil.FirstNonEmptyTrimmed(commonhttp.ExtractHTTPErrorDetail(responseBody), commonhttp.RedactErrorDetail(decoded.Message), fmt.Sprintf("upload failed with status %d", resp.StatusCode))) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - payload := make(map[string]string, len(state.payload)) - for key, value := range state.payload { - payload[key] = strings.TrimSpace(fmt.Sprint(value)) - } - endpoint, err := joinURL(resolveBaseURL(req.TrackerConfig), "/api/upload") - if err != nil { - return api.TrackerDryRunEntry{}, err - } - return api.TrackerDryRunEntry{ - Tracker: "RTF", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "rtf", - Description: state.description, - Endpoint: endpoint, - Payload: payload, - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" && (strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "") { - return uploadState{}, errors.New("trackers: RTF missing api_key or username/password") - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - var assets trackers.DescriptionAssets - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - } - description := buildDescription(assets) - torrentBytes, err := os.ReadFile(torrentPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: RTF read torrent file: %w", err) - } - releaseName := metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename) - payload := map[string]any{ - "name": releaseName, - "description": description, - "mediaInfo": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), - "nfo": "", - "url": imdbURL(req.Meta), - "descr": description, - "poster": resolvePoster(req.Meta), - "type": resolveType(req.Meta), - "screenshots": screenshots(assets.Screenshots), - "isAnonymous": req.TrackerConfig.Anon, - "file": base64.StdEncoding.EncodeToString(torrentBytes), - } - return uploadState{ - torrentPath: torrentPath, - releaseName: releaseName, - description: description, - payload: payload, - blockedReason: validateEligibility(req.Meta), - }, nil -} - -// resolveAPIKey validates configured RTF API auth and persists a refreshed token when credentials are used. -// Callers must complete no-upload eligibility gates before invoking it. -func resolveAPIKey(ctx context.Context, req trackers.UploadRequest) (string, error) { - apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) - baseURL := resolveBaseURL(req.TrackerConfig) - if apiKey != "" { - valid, err := testAPIKey(ctx, baseURL, apiKey) - if err == nil && valid { - return apiKey, nil - } - if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { - if err != nil { - return "", fmt.Errorf("trackers: RTF API key validation failed and username/password not configured: %w", err) - } - return "", errors.New("trackers: RTF API key invalid and username/password not configured") - } - } - if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { - return "", errors.New("trackers: RTF missing api_key or username/password") - } - refreshed, err := refreshAPIKey(ctx, baseURL, req.TrackerConfig) - if err != nil { - return "", err - } - if err := persistRefreshedAPIKey(ctx, req.AppConfig.MainSettings.DBPath, refreshed); err != nil && req.Logger != nil { - req.Logger.Warnf("trackers: RTF failed to persist refreshed API key: %v", err) - } - return refreshed, nil -} - -// ResolveSessionForTrackerAuthLogin validates RTF API auth or refreshes and -// persists the API key with configured credentials for tracker-auth checks. -func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, _ api.TrackerAuthLoginRequest) error { - apiKey := strings.TrimSpace(cfg.APIKey) - baseURL := resolveBaseURL(cfg) - if apiKey != "" { - valid, err := testAPIKey(ctx, baseURL, apiKey) - if err == nil && valid { - return nil - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - if err != nil { - return fmt.Errorf("trackers: RTF API key validation failed and username/password not configured: %w", err) - } - return errors.New("trackers: RTF API key invalid and username/password not configured") - } - } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return errors.New("trackers: RTF missing api_key or username/password") - } - refreshed, err := refreshAPIKey(ctx, baseURL, cfg) - if err != nil { - return err - } - if err := persistRefreshedAPIKey(ctx, dbPath, refreshed); err != nil { - return err - } - return nil -} - -func testAPIKey(ctx context.Context, baseURL string, apiKey string) (bool, error) { - testURL, err := joinURL(baseURL, "/api/test") - if err != nil { - return false, err - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) - if err != nil { - return false, fmt.Errorf("trackers: RTF create API test request: %w", err) - } - httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) - resp, err := newHTTPClient().Do(httpReq) - if err != nil { - return false, fmt.Errorf("trackers: RTF API test request: %w", err) - } - defer resp.Body.Close() - return resp.StatusCode == http.StatusOK, nil -} - -func refreshAPIKey(ctx context.Context, baseURL string, cfg config.TrackerConfig) (string, error) { - payload := map[string]string{ - "username": strings.TrimSpace(cfg.Username), - "password": strings.TrimSpace(cfg.Password), - } - body, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("trackers: RTF marshal API login payload: %w", err) - } - loginURL, err := joinURL(baseURL, "/api/login") - if err != nil { - return "", err - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(string(body))) - if err != nil { - return "", fmt.Errorf("trackers: RTF create API login request: %w", err) - } - httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Content-Type", "application/json") - resp, err := newHTTPClient().Do(httpReq) - if err != nil { - return "", fmt.Errorf("trackers: RTF API login request: %w", err) - } - defer resp.Body.Close() - responseBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("trackers: RTF API login failed status=%d", resp.StatusCode) - } - var decoded struct { - Token string `json:"token"` - } - if err := json.Unmarshal(responseBody, &decoded); err != nil { - return "", fmt.Errorf("trackers: RTF decode API login response: %w", err) - } - token := strings.TrimSpace(decoded.Token) - if token == "" { - return "", errors.New("trackers: RTF API login response missing token") - } - return token, nil -} - -func persistRefreshedAPIKey(ctx context.Context, dbPath string, token string) error { - dbPath = strings.TrimSpace(dbPath) - if dbPath == "" { - return errors.New("trackers: RTF persist refreshed API key: db path not configured") - } - repo, err := servicedb.OpenContext(ctx, dbPath) - if err != nil { - return fmt.Errorf("trackers: RTF persist refreshed API key open db: %w", err) - } - defer repo.Close() - cfg, err := config.LoadFromDatabase(ctx, repo) - if err != nil { - return fmt.Errorf("trackers: RTF persist refreshed API key load config: %w", err) - } - if cfg.Trackers.Trackers == nil { - cfg.Trackers.Trackers = map[string]config.TrackerConfig{} - } - trackerKey := "RTF" - for key := range cfg.Trackers.Trackers { - if strings.EqualFold(strings.TrimSpace(key), "RTF") { - trackerKey = key - break - } - } - trackerCfg := cfg.Trackers.Trackers[trackerKey] - trackerCfg.APIKey = strings.TrimSpace(token) - cfg.Trackers.Trackers[trackerKey] = trackerCfg - if err := config.SaveToDatabase(ctx, cfg, repo); err != nil { - return fmt.Errorf("trackers: RTF persist refreshed API key: %w", err) - } - return nil -} - -func resolveBaseURL(cfg config.TrackerConfig) string { - if value := strings.TrimRight(strings.TrimSpace(cfg.URL), "/"); value != "" { - return value - } - return defaultBaseURL -} - -// joinURL resolves path against baseURL and rejects malformed configured URLs instead of falling back to the default tracker host. -func joinURL(baseURL string, path string) (string, error) { - parsed, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", fmt.Errorf("trackers: RTF invalid base URL %q", baseURL) - } - ref, err := url.Parse(strings.TrimLeft(path, "/")) - if err != nil { - return "", fmt.Errorf("trackers: RTF invalid URL path %q: %w", path, err) - } - return parsed.ResolveReference(ref).String(), nil -} - -func buildDescription(assets trackers.DescriptionAssets) string { - return strings.TrimSpace(assets.Description) -} - -func validateEligibility(meta api.PreparedMetadata) string { - genres := strings.ToLower(genresText(meta) + "," + keywordsText(meta)) - for _, value := range []string{"xxx", "erotic", "porn", "adult", "orgy"} { - if strings.Contains(genres, value) { - return "adult content is not allowed" - } - } - limit := time.Now().UTC().AddDate(-10, 0, 3) - if t := releaseDate(meta); !t.IsZero() { - if t.After(limit) { - return "content must be at least 10 years old" - } - return "" - } - if year := resolveYear(meta); year > limit.Year() { - return "content must be at least 10 years old" - } - return "" -} - -func releaseDate(meta api.PreparedMetadata) time.Time { - if meta.ExternalMetadata.TMDB == nil { - return time.Time{} - } - for _, value := range []string{ - strings.TrimSpace(meta.ExternalMetadata.TMDB.ReleaseDate), - strings.TrimSpace(meta.ExternalMetadata.TMDB.LastAirDate), - strings.TrimSpace(meta.ExternalMetadata.TMDB.FirstAirDate), - } { - if value == "" { - continue - } - if t, err := time.Parse("2006-01-02", value); err == nil { - return t - } - } - return time.Time{} -} - -func resolveYear(meta api.PreparedMetadata) int { - if meta.Release.Year > 0 { - return meta.Release.Year - } - if meta.ExternalMetadata.TMDB == nil { - return 0 - } - return meta.ExternalMetadata.TMDB.Year -} - -func resolveType(meta api.PreparedMetadata) string { - if !isTV(meta) { - return "401" - } - return "402" -} - -func screenshots(images []api.ScreenshotImage) []string { - out := make([]string, 0, len(images)) - for _, image := range images { - if raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)); raw != "" { - out = append(out, raw) - } - } - return out -} - -func imdbURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.ExternalIDs.IMDBID) -} - -func resolvePoster(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB == nil { - return "" - } - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Poster) -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} - -func genresText(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Genres, meta.Release.Genre) - } - return metautil.FirstNonEmptyTrimmed(meta.Release.Genre) -} - -func keywordsText(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB == nil { - return "" - } - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) -} diff --git a/internal/trackers/impl/rtf/upload_test.go b/internal/trackers/impl/rtf/upload_test.go deleted file mode 100644 index aa95bafc..00000000 --- a/internal/trackers/impl/rtf/upload_test.go +++ /dev/null @@ -1,312 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package rtf - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - servicedb "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildUploadDryRunUsesDescriptionPayloadAndLeavesNFOEmpty(t *testing.T) { - root := t.TempDir() - torrentPath := filepath.Join(root, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - entry, err := buildUploadDryRun(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - DescriptionOverride: "Custom description", - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if entry.Payload["description"] != "Custom description" { - t.Fatalf("expected description payload, got %#v", entry.Payload) - } - if entry.Payload["descr"] != "Custom description" { - t.Fatalf("expected descr payload mirror, got %#v", entry.Payload) - } - if entry.Payload["nfo"] != "" { - t.Fatalf("expected empty nfo payload, got %#v", entry.Payload["nfo"]) - } -} - -func TestUploadRefreshesExpiredAPIKeyAndPersistsIt(t *testing.T) { - root := t.TempDir() - torrentPath := filepath.Join(root, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - dbPath := filepath.Join(root, "upbrr.db") - seedRTFConfig(t, dbPath, config.TrackerConfig{APIKey: "old-token", Username: "user", Password: "pass"}) - - var testedToken string - var loginCalled bool - var uploadToken string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/test": - testedToken = r.Header.Get("Authorization") - w.WriteHeader(http.StatusUnauthorized) - case "/api/login": - loginCalled = true - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"token":"new-token"}`)) - case "/api/upload": - uploadToken = r.Header.Get("Authorization") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - summary, err := upload(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - ReleaseName: "Release", - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - APIKey: "old-token", - Username: "user", - Password: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - }) - if err != nil { - t.Fatalf("upload: %v", err) - } - if summary.Uploaded != 1 { - t.Fatalf("expected upload success, got %#v", summary) - } - if testedToken != "old-token" { - t.Fatal("expected old token to be tested") - } - if !loginCalled { - t.Fatal("expected expired token to trigger API login") - } - if uploadToken != "new-token" { - t.Fatal("expected upload to use refreshed token") - } - if got := loadStoredRTFAPIKey(t, dbPath); got != "new-token" { - t.Fatal("expected refreshed token persisted") - } -} - -func TestUploadBlockedExpiredAPIKeyDoesNotRefreshOrPersist(t *testing.T) { - root := t.TempDir() - torrentPath := filepath.Join(root, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - dbPath := filepath.Join(root, "upbrr.db") - seedRTFConfig(t, dbPath, config.TrackerConfig{APIKey: "old-token", Username: "user", Password: "pass"}) - - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - http.NotFound(w, r) - })) - t.Cleanup(server.Close) - - _, err := upload(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - ReleaseName: "Recent Release", - Release: api.ReleaseInfo{Year: 9999}, - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - APIKey: "old-token", - Username: "user", - Password: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - }) - if err == nil { - t.Fatal("expected blocked upload error") - } - if !strings.Contains(err.Error(), "content must be at least 10 years old") { - t.Fatalf("expected eligibility error, got %v", err) - } - if requests != 0 { - t.Fatalf("blocked upload must not call RTF auth or upload endpoints, got %d requests", requests) - } - if got := loadStoredRTFAPIKey(t, dbPath); got != "old-token" { - t.Fatal("blocked upload must leave stored token unchanged") - } -} - -func TestUploadUsesValidAPIKeyWithoutRefresh(t *testing.T) { - root := t.TempDir() - torrentPath := filepath.Join(root, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - var loginCalled bool - var uploadToken string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/test": - w.WriteHeader(http.StatusOK) - case "/api/login": - loginCalled = true - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"token":"new-token"}`)) - case "/api/upload": - uploadToken = r.Header.Get("Authorization") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - if _, err := upload(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - ReleaseName: "Release", - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - APIKey: "valid-token", - }, - }); err != nil { - t.Fatalf("upload: %v", err) - } - if loginCalled { - t.Fatal("valid token should not trigger API login") - } - if uploadToken != "valid-token" { - t.Fatal("expected upload to use original token") - } -} - -func TestUploadGeneratesMissingAPIKeyFromCredentials(t *testing.T) { - root := t.TempDir() - torrentPath := filepath.Join(root, "test.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - dbPath := filepath.Join(root, "upbrr.db") - seedRTFConfig(t, dbPath, config.TrackerConfig{Username: "user", Password: "pass"}) - - var testCalled bool - var uploadToken string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/test": - testCalled = true - w.WriteHeader(http.StatusOK) - case "/api/login": - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"token":"generated-token"}`)) - case "/api/upload": - uploadToken = r.Header.Get("Authorization") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - if _, err := upload(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - Meta: api.PreparedMetadata{ - TorrentPath: torrentPath, - ReleaseName: "Release", - }, - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - Username: "user", - Password: "pass", - }, - AppConfig: config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}, - }); err != nil { - t.Fatalf("upload: %v", err) - } - if testCalled { - t.Fatal("missing token should refresh directly without API test") - } - if uploadToken != "generated-token" { - t.Fatal("expected upload to use generated token") - } - if got := loadStoredRTFAPIKey(t, dbPath); got != "generated-token" { - t.Fatal("expected generated token persisted") - } -} - -func TestUploadRejectsMalformedBaseURL(t *testing.T) { - _, err := upload(context.Background(), trackers.UploadRequest{ - Tracker: "RTF", - TrackerConfig: config.TrackerConfig{URL: "not a url", APIKey: "token"}, - }) - if err == nil { - t.Fatal("expected malformed base URL error") - } - if !strings.Contains(err.Error(), "invalid base URL") { - t.Fatalf("expected invalid base URL error, got %v", err) - } -} - -func seedRTFConfig(t *testing.T, dbPath string, trackerCfg config.TrackerConfig) { - t.Helper() - - repo, err := servicedb.OpenContext(context.Background(), dbPath) - if err != nil { - t.Fatalf("open repo: %v", err) - } - defer repo.Close() - if err := repo.MigrateContext(context.Background()); err != nil { - t.Fatalf("MigrateContext: %v", err) - } - cfg := config.Config{ - MainSettings: config.MainSettingsConfig{DBPath: dbPath}, - Trackers: config.TrackersConfig{ - Trackers: map[string]config.TrackerConfig{"RTF": trackerCfg}, - }, - } - if err := config.SaveToDatabase(context.Background(), &cfg, repo); err != nil { - t.Fatalf("SaveToDatabase: %v", err) - } -} - -func loadStoredRTFAPIKey(t *testing.T, dbPath string) string { - t.Helper() - - repo, err := servicedb.OpenContext(context.Background(), dbPath) - if err != nil { - t.Fatalf("open repo: %v", err) - } - defer repo.Close() - cfg, err := config.LoadFromDatabase(context.Background(), repo) - if err != nil { - t.Fatalf("LoadFromDatabase: %v", err) - } - return cfg.Trackers.Trackers["RTF"].APIKey -} diff --git a/internal/trackers/impl/spd/definition.go b/internal/trackers/impl/spd/definition.go deleted file mode 100644 index d88e785a..00000000 --- a/internal/trackers/impl/spd/definition.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package spd - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "SPD" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "spd", Description: description}, nil -} diff --git a/internal/trackers/impl/spd/upload.go b/internal/trackers/impl/spd/upload.go deleted file mode 100644 index 9ccd18ea..00000000 --- a/internal/trackers/impl/spd/upload.go +++ /dev/null @@ -1,461 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package spd - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "time" - "unicode" - - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://speedapp.io" - uploadURL = baseURL + "/api/upload" -) - -type uploadState struct { - torrentPath string - description string - releaseName string - payload map[string]any - questionnaire *api.TrackerQuestionnaire - blockedReason string -} - -type uploadResponse struct { - Status bool `json:"status"` - Error bool `json:"error"` - DownloadURL string `json:"downloadUrl"` - Torrent struct { - ID any `json:"id"` - } `json:"torrent"` -} - -type channelResult struct { - ID any `json:"id"` - Tag string `json:"tag"` -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: SPD %s", state.blockedReason) - } - - body, err := json.Marshal(state.payload) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: SPD marshal upload payload: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, strings.NewReader(string(body))) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: SPD build upload request: %w", err) - } - httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", strings.TrimSpace(req.TrackerConfig.APIKey)) - - resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: SPD upload request: %w", err) - } - defer resp.Body.Close() - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: SPD read upload response: %w", err) - } - - var decoded uploadResponse - if err := json.Unmarshal(responseBody, &decoded); err != nil { - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "SPD", "upload_failure", responsePreview, ".txt") - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return api.UploadSummary{}, commonhttp.UploadHTTPError("SPD", resp.StatusCode, responsePreview) - } - return api.UploadSummary{}, fmt.Errorf("trackers: SPD decode response: %w", err) - } - if resp.StatusCode == http.StatusOK && decoded.Status && !decoded.Error { - torrentID := strings.TrimSpace(fmt.Sprint(decoded.Torrent.ID)) - artifactPath := "" - if torrentID != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "SPD") - if err == nil { - if dlErr := downloadTrackerTorrent(ctx, baseURL+"/api/torrent/"+torrentID+"/download", req.TrackerConfig.APIKey, artifactPath); dlErr != nil { - artifactPath = "" - } - } - } - return api.UploadSummary{ - Uploaded: 1, - UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "SPD", - TorrentID: torrentID, - TorrentURL: baseURL + "/browse/" + torrentID, - DownloadURL: baseURL + "/api/torrent/" + torrentID + "/download", - TorrentPath: artifactPath, - }}, - }, nil - } - - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "SPD", "upload_failure", responsePreview, ".json") - return api.UploadSummary{}, commonhttp.UploadHTTPError("SPD", resp.StatusCode, responsePreview) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - payload := make(map[string]string, len(state.payload)) - for key, value := range state.payload { - payload[key] = strings.TrimSpace(fmt.Sprint(value)) - } - return api.TrackerDryRunEntry{ - Tracker: "SPD", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "spd", - Description: state.description, - Endpoint: uploadURL, - Payload: payload, - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "file", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { - return uploadState{}, errors.New("trackers: SPD missing api_key") - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - channelID, blockedReason, questionnaire := resolveChannel(ctx, req) - torrentBytes, err := os.ReadFile(torrentPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: SPD read torrent file: %w", err) - } - releaseName := normalizeName(metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename)) - payload := map[string]any{ - "bdInfo": trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, req.Meta), - "coverPhotoUrl": metautil.FirstNonEmptyTrimmed(req.Meta.ExternalMetadata.TMDB.Backdrop, req.Meta.ExternalMetadata.TMDB.Poster), - "description": genresText(req.Meta), - "media_info": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), - "name": releaseName, - "nfo": "", - "plot": metautil.FirstNonEmptyTrimmed(req.Meta.EpisodeOverview, req.Meta.ExternalMetadata.TMDB.Overview), - "poster": metautil.FirstNonEmptyTrimmed(req.Meta.ExternalMetadata.TMDB.Poster), - "technicalDetails": description, - "screenshots": combineUniqueScreenshots(assets.MenuImages, assets.Screenshots), - "type": resolveCategory(req.Meta), - "url": imdbURL(req.Meta), - "channel": channelID, - "file": base64.StdEncoding.EncodeToString(torrentBytes), - } - return uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: releaseName, - payload: payload, - questionnaire: questionnaire, - blockedReason: blockedReason, - }, nil -} - -func resolveChannel(ctx context.Context, req trackers.UploadRequest) (string, string, *api.TrackerQuestionnaire) { - answers := questionnaireAnswers(req.Meta) - input := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["channel"]), strings.TrimSpace(req.TrackerConfig.Channel)) - if input == "" { - return "1", "", nil - } - if digitsOnly(input) { - return input, "", nil - } - id, err := lookupChannelID(ctx, req.TrackerConfig.APIKey, input) - if err == nil && id != "" { - return id, "", nil - } - return "", "answer the channel questionnaire with a valid channel id or tag", &api.TrackerQuestionnaire{ - Tracker: "SPD", - Fields: []api.TrackerQuestionnaireField{{ - Key: "channel", Label: "Channel", Kind: "text", Value: input, Placeholder: "1 or channel tag", Required: true, - }}, - } -} - -func lookupChannelID(ctx context.Context, apiKey string, input string) (string, error) { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/channel?search="+url.QueryEscape(input), nil) - if err != nil { - return "", fmt.Errorf("trackers: SPD build channel lookup request: %w", err) - } - httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) - resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(httpReq) - if err != nil { - return "", fmt.Errorf("trackers: SPD channel lookup request: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - var decoded []channelResult - if err := json.Unmarshal(body, &decoded); err != nil { - return "", fmt.Errorf("trackers: SPD unmarshal channel lookup response: %w", err) - } - for _, item := range decoded { - if strings.EqualFold(strings.TrimSpace(item.Tag), strings.TrimSpace(input)) { - return strings.TrimSpace(fmt.Sprint(item.ID)), nil - } - } - return "", errors.New("channel not found") -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - meta := req.Meta - var parts []string - - // Avoid unnecessary descriptions - if strings.TrimSpace(assets.Description) != "" || strings.TrimSpace(meta.EpisodeOverview) != "" { - // Custom Header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // Logo - if logo, logoSize := descriptionunit3d.ResolveLogo(meta, req.AppConfig); logo != "" { - parts = append(parts, fmt.Sprintf("[center][img=%d]https://image.tmdb.org/t/p/w300/%s[/img][/center]", logoSize, logo)) - } - - // TV Details - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") - } - - // User Description - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // custom user signature - if signature := strings.TrimSpace(req.AppConfig.Description.CustomSignature); signature != "" { - parts = append(parts, signature) - } - - // Signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("[url=%s]%s[/url]", link, text)) - - // Join and finalize - description := strings.Join(parts, "\n\n") - finalized := bbcode.FinalizeTrackerDescription("SPD", description) - - // Debug saving - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "SPD", req.AppConfig.MainSettings.DBPath, finalized, req.Logger) - } - - return finalized -} - -func resolveCategory(meta api.PreparedMetadata) string { - romanian := hasRomanian(meta) - if containsWord(genresText(meta), "documentary") || containsWord(keywordsText(meta), "documentary") { - if romanian { - return "63" - } - return "9" - } - if meta.Anime { - return "3" - } - if isTV(meta) { - if meta.TVPack { - if romanian { - return "66" - } - return "41" - } - if isSD(meta.Release.Resolution) { - if romanian { - return "46" - } - return "45" - } - if romanian { - return "44" - } - return "43" - } - if meta.Release.Resolution == "2160p" && !strings.EqualFold(meta.Type, "DISC") { - if romanian { - return "57" - } - return "61" - } - if strings.EqualFold(meta.Type, "DISC") { - if romanian { - return "24" - } - return "17" - } - if romanian { - return "29" - } - return "8" -} - -func hasRomanian(meta api.PreparedMetadata) bool { - for _, value := range append([]string{}, append(meta.AudioLanguages, meta.SubtitleLanguages...)...) { - if strings.EqualFold(strings.TrimSpace(value), "romanian") { - return true - } - } - for _, code := range meta.ExternalMetadata.TMDB.OriginCountry { - if strings.EqualFold(strings.TrimSpace(code), "RO") { - return true - } - } - return false -} - -func combineUniqueScreenshots(menu []api.ScreenshotImage, normal []api.ScreenshotImage) []string { - var out []string - seen := make(map[string]struct{}) - - for _, image := range menu { - u := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) - if u != "" && !isSeen(seen, u) { - out = append(out, u) - seen[u] = struct{}{} - } - } - for _, image := range normal { - u := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) - if u != "" && !isSeen(seen, u) { - out = append(out, u) - seen[u] = struct{}{} - } - } - return out -} - -func isSeen(seen map[string]struct{}, url string) bool { - _, ok := seen[url] - return ok -} - -func normalizeName(input string) string { - mapper := func(r rune) rune { - if r > unicode.MaxASCII { - return -1 - } - if strings.ContainsRune(`\/*?"<>|`, r) { - return -1 - } - return r - } - return strings.Join(strings.Fields(strings.Map(mapper, strings.ReplaceAll(input, ":", " -"))), " ") -} - -func imdbURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) -} - -func downloadTrackerTorrent(ctx context.Context, urlValue string, apiKey string, output string) error { - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, urlValue, nil) - if err != nil { - return fmt.Errorf("trackers: SPD build torrent download request: %w", err) - } - httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) - resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(httpReq) - if err != nil { - return fmt.Errorf("trackers: SPD torrent download request: %w", err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("trackers: SPD read torrent response: %w", err) - } - if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil { - return fmt.Errorf("trackers: SPD create torrent output dir: %w", err) - } - if err := os.WriteFile(output, body, 0o600); err != nil { - return fmt.Errorf("trackers: SPD write torrent output: %w", err) - } - return nil -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["SPD"] -} - -func digitsOnly(value string) bool { - _, err := strconv.Atoi(strings.TrimSpace(value)) - return err == nil -} - -func containsWord(a string, b string) bool { - return strings.Contains(strings.ToLower(a), strings.ToLower(b)) -} - -func isSD(res string) bool { - return strings.HasPrefix(strings.TrimSpace(res), "480") || strings.HasPrefix(strings.TrimSpace(res), "576") || strings.HasPrefix(strings.TrimSpace(res), "540") -} - -func genresText(meta api.PreparedMetadata) string { - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Genres, meta.Release.Genre) -} - -func keywordsText(meta api.PreparedMetadata) string { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} diff --git a/internal/trackers/impl/standalone/ant/bbcode.go b/internal/trackers/impl/standalone/ant/bbcode.go new file mode 100644 index 00000000..0d1ee3b8 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/bbcode.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.ConvertToAlign(value) + value = bbcode.RemoveImageResize(value) + value = bbcode.RemoveSup(value) + value = bbcode.RemoveSub(value) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/ant/bbcode_test.go b/internal/trackers/impl/standalone/ant/bbcode_test.go new file mode 100644 index 00000000..242a1bd9 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/bbcode_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "strings" + "testing" +) + +func TestFinalizeDescription(t *testing.T) { + input := "[center][img=350]https://img.example/a.png[/img][/center]\n[sup]x[/sup]\n[sub]y[/sub]\n[list]z[/list]" + got := finalizeDescription(input) + if !strings.Contains(got, "[align=center][img]https://img.example/a.png[/img][/align]") { + t.Fatalf("expected center/img tags normalized for ANT, got %q", got) + } + if strings.Contains(got, "[sup]") || strings.Contains(got, "[sub]") || strings.Contains(got, "[list]") { + t.Fatalf("expected ANT cleanup to strip unsupported tags, got %q", got) + } +} diff --git a/internal/trackers/impl/standalone/ant/data.go b/internal/trackers/impl/standalone/ant/data.go new file mode 100644 index 00000000..89e1b295 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/data.go @@ -0,0 +1,108 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +type dataLookup struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDataLookup returns an ANT lookup bound to cfg and httpClient. +func (d *Definition) NewDataLookup(cfg config.Config, httpClient *http.Client, _ api.Logger) trackers.DataLookup { + return &dataLookup{ + cfg: cfg, + http: httpClient, + endpoint: "https://anthelion.me/api.php", + } +} + +// Lookup searches ANT by filename and returns IMDb and TMDB identifiers from +// the sole result or the result containing the requested file. Disc uploads, +// missing credentials or names, non-success responses, and no match produce an +// empty result without an error. +func (l *dataLookup) Lookup(ctx context.Context, req trackers.DataLookupRequest) (trackers.DataLookupResult, error) { + if strings.TrimSpace(req.Meta.DiscType) != "" { + return trackers.DataLookupResult{}, nil + } + apiKey := antAPIKey(l.cfg) + fileName := strings.TrimSpace(req.SearchName) + if apiKey == "" || fileName == "" { + return trackers.DataLookupResult{}, nil + } + params := url.Values{ + "t": {"search"}, + "filename": {fileName}, + "o": {"json"}, + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, l.endpoint, nil) + if err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: ant request: %w", err) + } + httpReq.URL.RawQuery = params.Encode() + httpReq.Header.Set("User-Agent", "upbrr") + httpReq.Header.Set("X-Api-Key", apiKey) + resp, err := l.http.Do(httpReq) + if err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: ant request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return trackers.DataLookupResult{}, nil + } + var decoded struct { + Items []map[string]any `json:"item"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: ant decode: %w", err) + } + item := matchDataItem(decoded.Items, fileName) + if len(item) == 0 { + return trackers.DataLookupResult{}, nil + } + return trackers.DataLookupResult{ + TrackerID: "1", + IMDBID: antIMDB(item["imdb"]), + TMDBID: int(antInt(item["tmdb"])), + }, nil +} + +func matchDataItem(items []map[string]any, fileName string) map[string]any { + if len(items) == 1 { + return items[0] + } + baseName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) + for _, item := range items { + files, _ := item["files"].([]any) + for _, raw := range files { + entry, _ := raw.(map[string]any) + name := strings.ToLower(antString(entry["name"])) + if strings.EqualFold(name, fileName) || strings.TrimSuffix(name, filepath.Ext(name)) == baseName { + return item + } + } + } + return nil +} + +func antIMDB(value any) int { + text := strings.TrimPrefix(strings.ToLower(antString(value)), "tt") + parsed, _ := strconv.Atoi(text) + return parsed +} diff --git a/internal/trackers/impl/standalone/ant/data_test.go b/internal/trackers/impl/standalone/ant/data_test.go new file mode 100644 index 00000000..0668a18b --- /dev/null +++ b/internal/trackers/impl/standalone/ant/data_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" +) + +func TestDataLookupSendsAPIKeyHeader(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("apikey") != "" || r.URL.Query().Get("t") != "search" || r.URL.Query().Get("filename") != "Example.Release.mkv" { + t.Errorf("unexpected query: %s", r.URL.RawQuery) + } + if r.Header.Get("X-Api-Key") != "token" || r.Header.Get("User-Agent") == "" { + t.Error("unexpected request headers") + } + _ = json.NewEncoder(w).Encode(map[string]any{"item": []map[string]any{{"imdb": "tt1234567", "tmdb": 765}}}) + })) + defer server.Close() + + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"ANT": {APIKey: "token"}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected ANT data lookup") + } + lookup.endpoint = server.URL + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{SearchName: "Example.Release.mkv"}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if result.IMDBID != 1234567 || result.TMDBID != 765 { + t.Fatalf("unexpected result: %+v", result) + } +} diff --git a/internal/trackers/impl/standalone/ant/definition.go b/internal/trackers/impl/standalone/ant/definition.go new file mode 100644 index 00000000..aa42e984 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/definition.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + assets.Description = trackers.StripDefaultDescriptionSignature(assets.Description) + + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + + return trackers.DescriptionResult{ + Group: "ant", + Description: strings.TrimSpace(description), + }, nil +} diff --git a/internal/trackers/impl/standalone/ant/definition_test.go b/internal/trackers/impl/standalone/ant/definition_test.go new file mode 100644 index 00000000..221d3635 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/definition_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func (d *Definition) prepareDryRun(ctx context.Context, input trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + input.Intent = trackers.PreparationIntentDryRun + plan, failure := d.Prepare(ctx, input) + if failure != nil { + return api.TrackerDryRunEntry{}, failure + } + return plan.DryRun(), nil +} + +func TestDefinitionBuildUploadDryRunIncludesQuestionnaire(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "ANT", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{TMDBID: 123}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Genres: "Adult", Keywords: "adult"}, + }, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Questionnaire == nil { + t.Fatal("expected questionnaire") + } + if got := len(entry.Questionnaire.Fields); got != 3 { + t.Fatalf("expected 3 questionnaire fields, got %d", got) + } + if entry.Questionnaire.Fields[0].Key != "type" { + t.Fatalf("expected type field first, got %q", entry.Questionnaire.Fields[0].Key) + } + if entry.Questionnaire.Fields[0].Kind != "select" { + t.Fatalf("expected type field to use select control, got %q", entry.Questionnaire.Fields[0].Kind) + } + if got := len(entry.Questionnaire.Fields[0].Options); got != 4 { + t.Fatalf("expected 4 type options, got %d", got) + } + if entry.Questionnaire.Fields[2].Kind != "select" { + t.Fatalf("expected adult screens field to use select control, got %q", entry.Questionnaire.Fields[2].Kind) + } +} + +func TestDefinitionBuildUploadDryRunMarksManualTagsWhenOnlyIMDbGenresExist(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "ANT", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{TMDBID: 123}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Genres: "Action, Drama"}, + }, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := entry.Payload["flagchangereason"]; got != "User prompted to add tags manually" { + t.Fatalf("expected manual tag prompt reason, got %q", got) + } + if _, ok := entry.Payload["tags"]; ok { + t.Fatalf("expected imdb fallback genres to avoid automatic ANT tags, got %#v", entry.Payload) + } +} + +func TestDefinitionBuildUploadDryRunUsesBDInfoForBDMV(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "db.sqlite") + torrentPath := filepath.Join(tmp, "Movie.torrent") + sourcePath := filepath.Join(tmp, "Movie", "BDMV") + if err := os.MkdirAll(sourcePath, 0o700); err != nil { + t.Fatalf("create source path: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + meta := api.UploadSubject{ + SourcePath: sourcePath, + TorrentPath: torrentPath, + DiscType: "BDMV", + SelectedBDMVPlaylists: []api.PlaylistInfo{{File: "00001.MPLS"}}, + Identity: api.ExternalIdentity{TMDBID: 123}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Genres: "Action"}, + }, + TrackerQuestionnaireAnswers: map[string]map[string]string{ + "ANT": {"type": "Feature Film"}, + }, + } + tmpDir, _, err := paths.ReleaseTempDirFor(filepath.Join(tmp, "tmp"), sourcePath, meta.Release) + if err != nil { + t.Fatalf("resolve temp dir: %v", err) + } + bdinfoPath := paths.BDMVSummaryPath(tmpDir, "00001.MPLS") + if err := os.WriteFile(bdinfoPath, []byte("BDINFO_CONTENT"), 0o600); err != nil { + t.Fatalf("write bdinfo: %v", err) + } + + entry, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "ANT", + Meta: meta, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := entry.Payload["bdinfo"]; got != "BDINFO_CONTENT" { + t.Fatalf("expected BDInfo payload, got %q", got) + } + if got := entry.Payload["container_type"]; got != "m2ts" { + t.Fatalf("expected m2ts container type, got %q", got) + } + if _, ok := entry.Payload["mediainfo"]; ok { + t.Fatalf("expected BDMV upload to omit mediainfo, got %#v", entry.Payload) + } + if _, ok := entry.Payload["media"]; ok { + t.Fatalf("expected BDMV upload to omit media field, got %#v", entry.Payload) + } +} + +func TestResolveFlagsIncludesIMAXAndCriterionEdition(t *testing.T) { + t.Parallel() + + flags := resolveFlags(api.UploadSubject{Edition: "IMAX Criterion Collection"}) + if !containsString(flags, "IMAX") { + t.Fatalf("expected IMAX flag, got %#v", flags) + } + if !containsString(flags, "Criterion") { + t.Fatalf("expected Criterion flag from edition, got %#v", flags) + } +} + +func TestResolveReleaseGroupBansUpdatedGroups(t *testing.T) { + t.Parallel() + + for _, group := range []string{"EVO", "SM737"} { + if got, ok := resolveReleaseGroup(group); ok || got != "" { + t.Fatalf("expected %s to be banned, got %q ok=%t", group, got, ok) + } + } + if got, ok := resolveReleaseGroup("Flights"); !ok || got != "Flights" { + t.Fatalf("expected Flights to be allowed, got %q ok=%t", got, ok) + } +} + +func TestBuildDescriptionRemovesScreenshotOnlyBlockAndDefaultSignature(t *testing.T) { + description := buildDescription(trackers.PreparationInput{}, trackers.DescriptionAssets{ + Description: `[align=center] +[url=https://pixhost.to/fv71hr.png][img width=350]https://pixhost.to/fv71hr.png[/img][/url] +[/align] + +[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]`, + }) + if strings.TrimSpace(description) != "" { + t.Fatalf("expected screenshot-only/signature-only description removed, got %q", description) + } +} + +func containsString(values []string, target string) bool { + return slices.Contains(values, target) +} diff --git a/internal/trackers/impl/standalone/ant/description_assets_test.go b/internal/trackers/impl/standalone/ant/description_assets_test.go new file mode 100644 index 00000000..0b039832 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/description_assets_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, + Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "ANT", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "https://images.example.invalid/menu.png") + if strings.Contains(result.Description, "normal.png") { + t.Fatalf("normal screenshot leaked into ANT description: %q", result.Description) + } + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "ANT", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/ant/dupe.go b/internal/trackers/impl/standalone/ant/dupe.go new file mode 100644 index 00000000..93c99fb8 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/dupe.go @@ -0,0 +1,165 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: "https://anthelion.me/api.php", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := antAPIKey(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + params := url.Values{"t": {"search"}, "o": {"json"}} + switch { + case meta.Identity.TMDBID != 0: + params.Set("tmdb", strconv.Itoa(meta.Identity.TMDBID)) + case meta.Identity.IMDBID != 0: + params.Set("imdb", strconv.Itoa(meta.Identity.IMDBID)) + default: + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing tmdb/imdb id for ANT dupe search", nil) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.endpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "ANT request failed", err) + } + req.URL.RawQuery = params.Encode() + req.Header.Set("User-Agent", "upbrr") + req.Header.Set("X-Api-Key", apiKey) + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "ANT request failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "ANT search failed", nil) + } + var payload map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil || len(payload) == 0 { + return dupe.Failed(dupe.FailureResponseParse, "ANT search failed", err) + } + return dupe.Resolved(antDupeEntries(payload, meta.Release.Resolution), nil) +} + +func antDupeEntries(payload map[string]any, resolution string) []api.DupeEntry { + items, _ := payload["item"].([]any) + entries := make([]api.DupeEntry, 0, len(items)) + targetResolution := strings.ToLower(strings.TrimSpace(resolution)) + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok || targetResolution != "" && strings.ToLower(antString(item["resolution"])) != targetResolution { + continue + } + files := antFiles(item["files"]) + fileCount := int(antInt(item["fileCount"])) + if fileCount == 0 { + fileCount = len(files) + } + entry := api.DupeEntry{ + Name: antString(item["fileName"]), + Files: files, + FileCount: fileCount, + Link: antString(item["guid"]), + Download: strings.ReplaceAll(antString(item["link"]), "&", "&"), + } + if entry.Name == "" && len(files) > 0 { + entry.Name = files[0] + } + if size := antInt(item["size"]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + if flags, ok := item["flags"].([]any); ok { + for _, rawFlag := range flags { + if flag := antString(rawFlag); flag != "" { + entry.Flags = append(entry.Flags, flag) + } + } + } + entries = append(entries, entry) + } + return entries +} + +func antAPIKey(cfg config.Config) string { + for name, trackerCfg := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "ANT") { + return strings.TrimSpace(trackerCfg.APIKey) + } + } + return "" +} + +func antFiles(value any) []string { + rawFiles, _ := value.([]any) + files := make([]string, 0, len(rawFiles)) + for _, raw := range rawFiles { + if file, ok := raw.(map[string]any); ok { + if name := antString(file["name"]); name != "" { + files = append(files, name) + } + } + } + return files +} + +func antString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(fmt.Sprint(value), ".0"), ".00")) +} + +func antInt(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case int: + return int64(typed) + case int64: + return typed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/ant/dupe_test.go b/internal/trackers/impl/standalone/ant/dupe_test.go new file mode 100644 index 00000000..1caee39f --- /dev/null +++ b/internal/trackers/impl/standalone/ant/dupe_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func TestDupeSearcherSendsAPIKeyHeader(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + if query.Get("apikey") != "" { + t.Fatal("apikey should not be sent as a query parameter") + } + for key, want := range map[string]string{ + "t": "search", + "o": "json", + "tmdb": "123", + } { + if got := query.Get(key); got != want { + t.Fatalf("query %s = %q, want %q", key, got, want) + } + } + if got := req.Header.Get("X-Api-Key"); got != "token" { + t.Fatal("unexpected X-API-Key header") + } + if req.Header.Get("User-Agent") == "" { + t.Fatal("expected User-Agent header") + } + body := `{"item":[{"fileName":"Example.Release.2026.1080p-GRP","resolution":"1080p","guid":"https://example.invalid/torrents.php?id=1","link":"https://example.invalid/download.php?id=1"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + cfg := config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"ANT": {APIKey: "token"}}}} + searcher := dupe.NewAdapter(New(), "ANT", cfg, client, api.NopLogger{}) + entries, notes, err := adapterEvidence(searcher.Search(context.Background(), api.DuplicateSubject{Identity: api.ExternalIdentity{TMDBID: 123}, Release: api.ReleaseInfo{Resolution: "1080p"}})) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(notes) != 0 || len(entries) != 1 { + t.Fatalf("entries=%#v notes=%#v", entries, notes) + } +} + +func TestDupeSearcherMissingCredentialsSkips(t *testing.T) { + t.Parallel() + searcher := dupe.NewAdapter(New(), "ANT", config.Config{}, http.DefaultClient, api.NopLogger{}) + result := searcher.Search(context.Background(), api.DuplicateSubject{Identity: api.ExternalIdentity{TMDBID: 1}}) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunMissingCredentials || result.SafeMessage() == "" { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } +} diff --git a/internal/trackers/impl/standalone/ant/profile.go b/internal/trackers/impl/standalone/ant/profile.go new file mode 100644 index 00000000..a66293c9 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/profile.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "maps" + "slices" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns ANT identity, preparation, dupe, rules, bans, and policies. +func Profile() standalone.Profile { + groups := slices.Collect(maps.Keys(antBannedReleaseGroups)) + slices.Sort(groups) + return standalone.Profile{ + Name: "ANT", + BaseURL: "https://anthelion.me", + DescriptionGroup: "ant", + UploadContentMode: trackers.UploadContentModeScreenshots, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: &trackers.RuleSet{RequireMovieOnly: true}, + ArtifactPolicy: &trackers.ArtifactPolicy{MaxPieceSizeMiB: 128, MaxTorrentBytes: 250 << 10}, + BannedGroups: groups, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "ANT"}, + DupePolicy: &trackers.DupePolicy{DolbyVisionImpliesHDR: true}, + AudioPolicy: &trackers.AudioPolicy{ + AllowedLanguages: []string{"english"}, BlockEnglishOriginalWithForeign: true, + }, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeMovie, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"tracker.anthelion.me"}, WorkingTrackerID: "1", + }, + } +} + +// Definition extends the shared standalone definition with ANT data lookup. +type Definition struct{ *standalone.Definition } + +// New returns a fresh ANT definition from its tracker-local profile. +func New() *Definition { return &Definition{Definition: standalone.MustNew(Profile())} } diff --git a/internal/trackers/impl/standalone/ant/upload.go b/internal/trackers/impl/standalone/ant/upload.go new file mode 100644 index 00000000..59cbdbc1 --- /dev/null +++ b/internal/trackers/impl/standalone/ant/upload.go @@ -0,0 +1,902 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ant + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "mime/multipart" + "net/http" + "os" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" + "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const antUploadURL = "https://anthelion.me/api.php" + +var antTorrentIDPattern = regexp.MustCompile(`id=(\d+)`) + +var antDefaultSignaturePattern = regexp.MustCompile( + `(?is)\[(?:right|align=right)\]\s*\[url=https://github\.com/(?:Audionut|autobrr)/upbrr\].*?\[/url\]\s*\[/(?:right|align)\]`, +) +var antEmptyURLPattern = regexp.MustCompile(`(?is)\[url=[^\]]*]\s*\[/url\]`) + +var antBannedReleaseGroups = map[string]struct{}{ + "3LTON": {}, + "4yEo": {}, + "ADE": {}, + "AFG": {}, + "AniHLS": {}, + "AnimeRG": {}, + "AniURL": {}, + "AROMA": {}, + "aXXo": {}, + "Brrip": {}, + "CHD": {}, + "CM8": {}, + "CrEwSaDe": {}, + "d3g": {}, + "DDR": {}, + "DNL": {}, + "DeadFish": {}, + "ELiTE": {}, + "eSc": {}, + "EVO": {}, + "FaNGDiNG0": {}, + "FGT": {}, + "FRDS": {}, + "FUM": {}, + "HAiKU": {}, + "HD2DVD": {}, + "HDS": {}, + "HDTime": {}, + "Hi10": {}, + "ION10": {}, + "iPlanet": {}, + "JIVE": {}, + "KiNGDOM": {}, + "Leffe": {}, + "LiGaS": {}, + "LOAD": {}, + "MeGusta": {}, + "MkvCage": {}, + "mHD": {}, + "mSD": {}, + "NhaNc3": {}, + "nHD": {}, + "NOIVTC": {}, + "nSD": {}, + "Oj": {}, + "Ozlem": {}, + "PiRaTeS": {}, + "PRoDJi": {}, + "RAPiDCOWS": {}, + "RARBG": {}, + "RetroPeeps": {}, + "RDN": {}, + "REsuRRecTioN": {}, + "RMTeam": {}, + "SANTi": {}, + "SicFoI": {}, + "SPASM": {}, + "SM737": {}, + "SPDVD": {}, + "STUTTERSHIT": {}, + "TBS": {}, + "Telly": {}, + "TM": {}, + "UPiNSMOKE": {}, + "URANiME": {}, + "WAF": {}, + "xRed": {}, + "XS": {}, + "YIFY": {}, + "YTS": {}, + "Zeus": {}, + "ZKBL": {}, + "ZmN": {}, + "ZMNT": {}, +} + +type uploadState struct { + torrentPath string + description string + fields map[string]string + adultContent bool + manualTags bool + typeName string + tags string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state, req.Meta) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "ANT") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, state, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + state uploadState, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, antUploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ANT request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ANT upload request: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ANT read response: %w", err) + } + + payload := map[string]any{} + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &payload); err != nil { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return api.UploadSummary{}, errors.New("trackers: ANT json decode error, the API is probably down") + } + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || !antUploadSuccess(payload) { + return api.UploadSummary{}, antUploadError(resp.StatusCode, payload, bodyBytes) + } + + viewURL := strings.TrimSpace(stringValue(payload["view"])) + if viewURL == "" { + viewURL = strings.TrimSpace(stringValue(payload["link"])) + } + torrentID := "" + if matches := antTorrentIDPattern.FindStringSubmatch(viewURL); len(matches) > 1 { + torrentID = strings.TrimSpace(matches[1]) + } + + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, viewURL, "ANT"); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "ANT", + TorrentID: torrentID, + DownloadURL: viewURL, + TorrentURL: viewURL, + TorrentPath: artifactPath, + }}, + }, nil +} + +func buildUploadPreview(state uploadState, meta api.UploadSubject) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "ANT", + ReleaseName: resolveUploadName(meta), + DescriptionGroup: "ant", + Description: state.description, + Endpoint: antUploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + Questionnaire: buildQuestionnaire(meta, state), + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + select { + case <-ctx.Done(): + return uploadState{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: ANT missing api_key") + } + if req.Meta.Identity.TMDBID == 0 { + return uploadState{}, errors.New("trackers: ANT missing tmdb id") + } + + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + descriptionAssets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + descriptionAssets = trackers.DescriptionAssets{} + } + descriptionAssets.Description = trackers.StripDefaultDescriptionSignature(descriptionAssets.Description) + description := buildDescription(req, descriptionAssets) + + answers := standalone.QuestionnaireAnswers(req.Meta, "ANT") + typeName, typeID := resolveType(req.Meta, answers) + audio := resolveAudioFormat(req.Meta) + flags := resolveFlags(req.Meta) + tags, manualTags := resolveTags(req.Meta, answers) + adultContent := detectAdult(req.Meta) + safeScreens := resolveAdultScreensAllowed(answers, adultContent) + screenshots := resolveScreenshotPayload(descriptionAssets.Screenshots, safeScreens) + mediaFields, err := resolveMediaFields(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, err + } + + fields := map[string]string{ + "api_key": strings.TrimSpace(req.TrackerConfig.APIKey), + "action": "upload", + "tmdbid": strconv.Itoa(req.Meta.Identity.TMDBID), + "type": strconv.Itoa(typeID), + "audioformat": audio, + "release_desc": description, + "screenshots": screenshots, + } + maps.Copy(fields, mediaFields) + if len(flags) > 0 { + fields["flags[]"] = strings.Join(flags, ",") + } + if req.Meta.Scene { + fields["censored"] = "1" + } + if tags != "" { + fields["tags"] = tags + } + if releaseGroup, ok := resolveReleaseGroup(req.Meta.Tag); ok { + fields["releasegroup"] = releaseGroup + } else { + fields["noreleasegroup"] = "1" + } + if adultContent && screenshots != "" { + if !manualTags { + fields["flagchangereason"] = "Adult with screens uploaded with upbrr" + } else { + fields["flagchangereason"] = "Adult with screens uploaded with upbrr. User to add tags manually." + } + } else if manualTags { + fields["flagchangereason"] = "User prompted to add tags manually" + } + + return uploadState{ + torrentPath: torrentPath, + description: description, + fields: fields, + adultContent: adultContent, + manualTags: manualTags, + typeName: typeName, + tags: tags, + }, nil +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + var parts []string + + // Base description + base := strings.TrimSpace(antDefaultSignaturePattern.ReplaceAllString(assets.Description, "")) + report := bbcode.CleanPTPDescription(base, meta.DiscType) + userDesc := strings.TrimSpace(report.Description) + if userDesc == "" && base != "" && len(report.Images) == 0 { + userDesc = base + } + + if userDesc != "" { + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + logoURL, _ := unit3d.ResolveLogo(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig()) + if logoURL != "" { + if strings.HasSuffix(logoURL, ".svg") { + logoURL = strings.ReplaceAll(logoURL, ".svg", ".png") + } + parts = append(parts, "[align=center][img]"+logoURL+"[/img][/align]") + } + + // User Description + parts = append(parts, userDesc) + } + + // Disc menus + if len(assets.MenuImages) > 0 { + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + var shotParts []string + for _, img := range assets.MenuImages { + url := metautil.FirstNonEmptyTrimmed(img.RawURL, img.ImgURL, img.WebURL) + if url != "" { + shotParts = append(shotParts, "[img]"+url+"[/img]") + } + } + if len(shotParts) > 0 { + parts = append(parts, "[align=center]"+strings.Join(shotParts, " ")+"[/align]") + } + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + unit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // Join and finalize + description := strings.Join(parts, "\n\n") + + finalized := finalizeDescription(description) + + // Character replacements + replacer := strings.NewReplacer("•", "-", "’", "'", "–", "-") + finalized = replacer.Replace(finalized) + + finalized = strings.TrimSpace(antEmptyURLPattern.ReplaceAllString(finalized, "")) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + unit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "ANT", req.Runtime.DBPath, finalized, req.Logger) + } + + return finalized +} + +func resolveMediaFields(meta api.UploadSubject, dbPath string) (map[string]string, error) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + bdinfo, err := resolveBDInfo(meta, dbPath) + if err != nil { + return nil, err + } + if strings.TrimSpace(bdinfo) == "" { + return nil, errors.New("trackers: ANT missing BDInfo text") + } + return map[string]string{ + "bdinfo": bdinfo, + "container_type": "m2ts", + }, nil + } + + if strings.TrimSpace(meta.MediaInfoTextPath) == "" { + return nil, errors.New("trackers: ANT missing mediainfo text") + } + payload, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + return nil, fmt.Errorf("trackers: ANT read mediainfo: %w", err) + } + return map[string]string{"mediainfo": string(payload)}, nil +} + +func resolveBDInfo(meta api.UploadSubject, dbPath string) (string, error) { + if summary := strings.TrimSpace(meta.Disc.Summary); summary != "" { + return summary, nil + } + + path, err := resolveBDInfoPath(meta, dbPath) + if err != nil { + return "", err + } + if strings.TrimSpace(path) == "" { + return "", nil + } + payload, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("trackers: ANT read BDInfo: %w", err) + } + return string(payload), nil +} + +func resolveBDInfoPath(meta api.UploadSubject, dbPath string) (string, error) { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "", nil + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + path, err := paths.PrimaryBDMVSummaryPathFor(tmpRoot, meta.SourcePath, meta.Release, meta.SelectedBDMVPlaylists) + if err != nil { + return path, fmt.Errorf("trackers: ANT resolve BDInfo path: %w", err) + } + return path, nil +} + +func buildQuestionnaire(meta api.UploadSubject, state uploadState) *api.TrackerQuestionnaire { + current := standalone.QuestionnaireAnswers(meta, "ANT") + fields := make([]api.TrackerQuestionnaireField, 0, 3) + if strings.TrimSpace(state.typeName) == "" { + fields = append(fields, api.TrackerQuestionnaireField{ + Key: "type", + Label: "ANT Type", + Kind: "select", + Options: []string{"Feature Film", "Short Film", "Miniseries", "Other"}, + Value: strings.TrimSpace(current["type"]), + Placeholder: "Select a release type", + Help: "Pick the ANT content type for this release", + Required: true, + }) + } + if strings.TrimSpace(state.tags) == "" { + fields = append(fields, api.TrackerQuestionnaireField{ + Key: "tags", + Label: "Tags", + Kind: "text", + Value: strings.TrimSpace(current["tags"]), + Placeholder: "action, drama", + Help: "Comma-separated ANT tags", + Required: true, + }) + } + if state.adultContent { + fields = append(fields, api.TrackerQuestionnaireField{ + Key: "adult_screens", + Label: "Upload Screenshots", + Kind: "select", + Options: []string{"no", "yes"}, + Value: metautil.FirstNonEmptyTrimmed(strings.TrimSpace(current["adult_screens"]), "no"), + Placeholder: "Select yes or no", + Help: "Set to yes to include screenshots for adult content", + Required: true, + }) + } + if len(fields) == 0 { + return nil + } + return &api.TrackerQuestionnaire{Tracker: "ANT", Fields: fields} +} + +func resolveType(meta api.UploadSubject, answers map[string]string) (string, int) { + if text := normalizeTypeName(answers["type"]); text != "" { + return text, antTypeID(text) + } + if meta.ProviderMetadata.IMDB != nil { + imdbType := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.IMDB.Type)) + runtime := meta.ProviderMetadata.IMDB.RuntimeMinutes + switch imdbType { + case "movie", "tv movie", "tvmovie": + if runtime >= 45 || runtime == 0 { + return "Feature Film", 0 + } + return "Short Film", 1 + case "short": + return "Short Film", 1 + case "tv mini series": + return "Miniseries", 2 + case "comedy": + return "Other", 3 + } + } + keywords := strings.ToLower(strings.TrimSpace(resolveKeywords(meta))) + category := strings.ToLower(strings.TrimSpace(string(meta.Identity.Category))) + if category == "movie" { + runtime := 0 + if meta.ProviderMetadata.TMDB != nil { + runtime = meta.ProviderMetadata.TMDB.Runtime + } + if runtime >= 45 || runtime == 0 { + return "Feature Film", 0 + } + return "Short Film", 1 + } + if strings.Contains(keywords, "miniseries") { + return "Miniseries", 2 + } + if strings.Contains(keywords, "short") || strings.Contains(keywords, "short film") { + return "Short Film", 1 + } + if strings.Contains(keywords, "stand-up comedy") { + return "Other", 3 + } + return "", 0 +} + +func normalizeTypeName(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "feature film", "feature", "movie": + return "Feature Film" + case "short film", "short": + return "Short Film" + case "miniseries", "mini series", "mini-series": + return "Miniseries" + case "other", "comedy": + return "Other" + default: + return "" + } +} + +func antTypeID(value string) int { + switch normalizeTypeName(value) { + case "Short Film": + return 1 + case "Miniseries": + return 2 + case "Other": + return 3 + default: + return 0 + } +} + +func resolveAudioFormat(meta api.UploadSubject) string { + audio := strings.ToUpper(strings.TrimSpace(meta.Audio)) + switch { + case strings.Contains(audio, "DD+"), strings.Contains(audio, "EAC3"): + return "EAC3" + case strings.Contains(audio, " DD "), strings.HasPrefix(audio, "DD"), strings.Contains(audio, "AC3"): + return "AC3" + case strings.Contains(audio, "DTS-HD MA"), strings.Contains(audio, "DTS MA"): + return "DTSMA" + case strings.Contains(audio, "DTS"): + return "DTS" + case strings.Contains(audio, "TRUEHD"): + return "TrueHD" + case strings.Contains(audio, "FLAC"): + return "FLAC" + case strings.Contains(audio, "PCM"): + return "PCM" + case strings.Contains(audio, "OPUS"): + return "Opus" + case strings.Contains(audio, "AAC"): + return "AAC" + case strings.Contains(audio, "MP3"): + return "MP3" + case strings.Contains(audio, "MP2"): + return "MP2" + default: + return "Other" + } +} + +func resolveFlags(meta api.UploadSubject) []string { + flags := make([]string, 0, 12) + edition := strings.ReplaceAll(meta.Edition, "'", "") + for _, candidate := range []string{"Directors", "Extended", "Uncut", "Unrated", "4KRemaster", "IMAX"} { + if strings.Contains(edition, candidate) { + flags = append(flags, candidate) + } + } + if strings.Contains(meta.Audio, "Dual-Audio") { + flags = append(flags, "DualAudio") + } + if strings.Contains(meta.Audio, "Atmos") { + flags = append(flags, "Atmos") + } + if meta.HasCommentary { + flags = append(flags, "Commentary") + } + if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { + flags = append(flags, "3D") + } + if strings.Contains(strings.ToUpper(meta.HDR), "HDR") { + flags = append(flags, "HDR10") + } + if strings.Contains(strings.ToUpper(meta.HDR), "DV") { + flags = append(flags, "DV") + } + if strings.Contains(strings.ToUpper(meta.Distributor), "CRITERION") || strings.Contains(strings.ToUpper(meta.Edition), "CRITERION") { + flags = append(flags, "Criterion") + } + if strings.Contains(strings.ToUpper(meta.Type), "REMUX") { + flags = append(flags, "Remux") + } + return dedupeStrings(flags) +} + +func resolveTags(meta api.UploadSubject, answers map[string]string) (string, bool) { + if tagValue := normalizeTags(strings.TrimSpace(answers["tags"])); tagValue != "" { + return tagValue, true + } + values := make([]string, 0, 8) + if meta.ProviderMetadata.TMDB != nil { + values = append(values, splitTags(meta.ProviderMetadata.TMDB.Genres)...) + } + if len(values) == 0 { + if meta.ProviderMetadata.IMDB != nil && len(splitTags(meta.ProviderMetadata.IMDB.Genres)) > 0 { + return "", true + } + return "", true + } + allowed := map[string]struct{}{ + "action": {}, + "adventure": {}, + "animation": {}, + "comedy": {}, + "crime": {}, + "documentary": {}, + "drama": {}, + "family": {}, + "fantasy": {}, + "history": {}, + "horror": {}, + "music": {}, + "mystery": {}, + "romance": {}, + "sci.fi": {}, + "thriller": {}, + "war": {}, + "western": {}, + } + filtered := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := allowed[value]; ok { + filtered = append(filtered, value) + } + } + return strings.Join(dedupeStrings(filtered), ","), false +} + +func normalizeTags(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return strings.Join(dedupeStrings(splitTags(value)), ",") +} + +func splitTags(value string) []string { + items := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' }) + result := make([]string, 0, len(items)) + for _, item := range items { + normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(item, " ", "."))) + if normalized != "" { + result = append(result, normalized) + } + } + return result +} + +func resolveReleaseGroup(tag string) (string, bool) { + trimmed := strings.TrimSpace(strings.TrimPrefix(tag, "-")) + if trimmed == "" { + return "", false + } + if _, banned := antBannedReleaseGroups[trimmed]; banned { + return "", false + } + return trimmed, true +} + +func detectAdult(meta api.UploadSubject) bool { + candidates := []string{meta.Release.Genre, resolveKeywords(meta)} + if meta.ProviderMetadata.TMDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TMDB.Genres) + } + for _, candidate := range candidates { + lower := strings.ToLower(candidate) + for _, token := range []string{"xxx", "erotic", "porn", "adult", "orgy"} { + if strings.Contains(lower, token) { + return true + } + } + } + return false +} + +func resolveAdultScreensAllowed(answers map[string]string, adultContent bool) bool { + if !adultContent { + return true + } + switch strings.ToLower(strings.TrimSpace(answers["adult_screens"])) { + case "y", "yes", "true", "1": + return true + default: + return false + } +} + +func resolveScreenshotPayload(images []api.ScreenshotImage, allow bool) string { + if !allow || len(images) == 0 { + return "" + } + urls := make([]string, 0, 4) + for _, image := range images { + rawURL := strings.TrimSpace(image.RawURL) + if rawURL == "" { + rawURL = strings.TrimSpace(image.ImgURL) + } + if rawURL == "" { + continue + } + urls = append(urls, rawURL) + if len(urls) == 4 { + break + } + } + return strings.Join(urls, "\n") +} + +func resolveKeywords(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) + } + return "" +} + +func resolveUploadName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.ReleaseName); name != "" { + return name + } + if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { + return name + } + if name := strings.TrimSpace(meta.Filename); name != "" { + return name + } + return pathutil.Base(meta.SourcePath) +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if key == "flags[]" { + for item := range strings.SplitSeq(value, ",") { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if err := writer.WriteField(key, trimmed); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: ANT write multipart field %q: %w", key, err) + } + } + continue + } + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: ANT write multipart field %q: %w", key, err) + } + } + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: ANT open torrent file: %w", err) + } + defer file.Close() + part, err := writer.CreateFormFile("file_input", "torrent.torrent") + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: ANT create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: ANT copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: ANT close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func antUploadSuccess(payload map[string]any) bool { + if success, ok := payload["success"]; ok { + switch value := success.(type) { + case bool: + return value + case string: + return strings.EqualFold(strings.TrimSpace(value), "true") || strings.EqualFold(strings.TrimSpace(value), "success") + } + } + return strings.EqualFold(strings.TrimSpace(stringValue(payload["status"])), "success") +} + +func antUploadError(status int, payload map[string]any, body []byte) error { + text := strings.ToLower(compactJSON(payload)) + if status == http.StatusBadRequest { + switch { + case strings.Contains(text, "same infohash"): + if viewURL := strings.TrimSpace(stringValue(payload["view"])); viewURL != "" { + return fmt.Errorf("trackers: ANT same infohash already exists: %s", commonhttp.RedactErrorDetail(viewURL)) + } + return errors.New("trackers: ANT same infohash already exists") + case strings.Contains(text, "exact same"): + return errors.New("trackers: ANT exact same media file already exists") + } + } + if detail := commonhttp.ExtractHTTPErrorDetail(body); detail != "" { + return fmt.Errorf("trackers: ANT upload failed status=%d: %s", status, detail) + } + switch status { + case http.StatusForbidden: + return errors.New("trackers: ANT wrong API key or insufficient permissions") + case http.StatusInternalServerError: + return errors.New("trackers: ANT internal server error") + case http.StatusBadGateway: + return errors.New("trackers: ANT bad gateway") + } + if message := strings.TrimSpace(stringValue(payload["error"])); message != "" { + return fmt.Errorf("trackers: ANT api error: %s", commonhttp.RedactErrorDetail(message)) + } + return commonhttp.UploadHTTPError("ANT", status, body) +} + +func compactJSON(payload map[string]any) string { + if len(payload) == 0 { + return "" + } + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} diff --git a/internal/trackers/impl/standalone/ar/definition.go b/internal/trackers/impl/standalone/ar/definition.go new file mode 100644 index 00000000..01671cac --- /dev/null +++ b/internal/trackers/impl/standalone/ar/definition.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + var ( + err error + assets trackers.DescriptionAssets + ) + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + } + + description := buildDescription(req.Meta, req.Runtime.DBPath, assets) + return trackers.DescriptionResult{ + Group: "ar", + Description: strings.TrimSpace(description), + }, nil +} diff --git a/internal/trackers/impl/standalone/ar/definition_test.go b/internal/trackers/impl/standalone/ar/definition_test.go new file mode 100644 index 00000000..549b1ec1 --- /dev/null +++ b/internal/trackers/impl/standalone/ar/definition_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func prepareDryRun(ctx context.Context, input trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + input.Intent = trackers.PreparationIntentDryRun + plan, failure := New().Prepare(ctx, input) + if failure != nil { + return api.TrackerDryRunEntry{}, failure + } + return plan.DryRun(), nil +} + +func TestResolveTypeID(t *testing.T) { + t.Parallel() + + if got := resolveTypeID(api.UploadSubject{ + Type: "REMUX", + Source: "Blu-ray", + }); got != "14" { + t.Fatalf("expected bluray remux type 14, got %q", got) + } + + if got := resolveTypeID(api.UploadSubject{ + Anime: true, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }); got != "16" { + t.Fatalf("expected anime hd type 16, got %q", got) + } + + if got := resolveTypeID(api.UploadSubject{ + TVPack: true, + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{Resolution: "480p"}, + }); got != "4" { + t.Fatalf("expected tv pack sd type 4, got %q", got) + } + + if got := resolveTypeID(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Genres: "Drama", Keywords: "adult"}, + }, + }); got != "13" { + t.Fatalf("expected adult movie type 13, got %q", got) + } +} + +func TestDefinitionBuildUploadDryRunBlockedWithoutPoster(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "AR", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Release: api.ReleaseInfo{Title: "Movie", Resolution: "1080p"}, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + }, + TrackerConfig: config.TrackerConfig{}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Status != "blocked" { + t.Fatalf("expected blocked status, got %q", entry.Status) + } + if entry.Message != "missing poster URL" { + t.Fatalf("expected poster block message, got %q", entry.Message) + } +} diff --git a/internal/trackers/impl/standalone/ar/dupe.go b/internal/trackers/impl/standalone/ar/dupe.go new file mode 100644 index 00000000..50e48dcc --- /dev/null +++ b/internal/trackers/impl/standalone/ar/dupe.go @@ -0,0 +1,220 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + arBrowseEndpoint = "https://alpharatio.cc/ajax.php" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + logger api.Logger +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + logger: logger, + } +} + +func (h dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if h.http == nil { + return dupe.Failed(dupe.FailureInternal, "AR handler misconfigured: no HTTP client", nil) + } + + query := arSearchQuery(meta) + if query == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing title for AR dupe search", nil) + } + + cookies, cookiePath, err := h.resolveCookies(ctx) + if err != nil || len(cookies) == 0 { + if err != nil && h.logger != nil { + h.logger.Debugf("dupechecking: AR cookie resolution failed: %v", err) + } + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid AR cookies", nil) + } + if h.logger != nil && cookiePath != "" { + h.logger.Debugf("dupechecking: AR using stored cookies from %s", cookiePath) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, arBrowseEndpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "AR request failed", fmt.Errorf("build AR request: %w", err)) + } + params := req.URL.Query() + params.Set("action", "browse") + params.Set("searchstr", query) + req.URL.RawQuery = params.Encode() + req.Header.Set("User-Agent", "upbrr") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + resp, err := h.http.Do(req) + if err != nil { + if h.logger != nil { + h.logger.Debugf("dupechecking: AR request failed: %v", err) + } + return dupe.Failed(dupe.FailureRequest, "AR request failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if h.logger != nil { + h.logger.Debugf("dupechecking: AR returned status %d", resp.StatusCode) + } + return dupe.Failed(dupe.FailureResponseStatus, "AR search returned non-success status", nil) + } + + var payload arResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + if h.logger != nil { + h.logger.Debugf("dupechecking: AR response decode failed: %v", err) + } + return dupe.Failed(dupe.FailureResponseParse, "AR response decode failed", err) + } + if !strings.EqualFold(strings.TrimSpace(payload.Status), "success") { + return dupe.Failed(dupe.FailureResponseStatus, "AR API returned non-success status", nil) + } + + entries := make([]api.DupeEntry, 0, len(payload.Response.Results)) + for _, result := range payload.Response.Results { + name := strings.TrimSpace(result.GroupName) + if name == "" { + continue + } + entry := api.DupeEntry{ + Name: name, + Files: []string{name}, + FileCount: result.FileCount, + ID: strconv.FormatInt(result.TorrentID, 10), + Link: "https://alpharatio.cc/torrents.php?id=" + strconv.FormatInt( + result.GroupID, + 10, + ) + "&torrentid=" + strconv.FormatInt( + result.TorrentID, + 10, + ), + Download: "https://alpharatio.cc/torrents.php?action=download&id=" + strconv.FormatInt(result.TorrentID, 10), + } + if result.Size > 0 { + entry.SizeKnown = true + entry.SizeBytes = result.Size + } + entries = append(entries, entry) + } + + return dupe.Resolved(entries, nil) +} + +func (h dupeSearcher) resolveCookies(ctx context.Context) ([]*http.Cookie, string, error) { + arURL, _ := url.Parse("https://alpharatio.cc/") + merged := map[string]*http.Cookie{} + + if h.http != nil && h.http.Jar != nil && arURL != nil { + for _, cookie := range h.http.Jar.Cookies(arURL) { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" { + continue + } + merged[cookie.Name] = cookie + } + } + if len(merged) > 0 { + if h.logger != nil { + h.logger.Debugf("dupechecking: AR using %d cookies from HTTP client jar", len(merged)) + } + return mapCookiesToSlice(merged), "", nil + } + + loaded, err := cookiepkg.LoadTrackerHTTPCookies(ctx, h.cfg.MainSettings.DBPath, "AR", "alpharatio.cc") + if err != nil { + return nil, "", fmt.Errorf("dupechecking: %w", err) + } + for _, cookie := range loaded { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" { + continue + } + merged[cookie.Name] = cookie + } + if len(merged) == 0 { + return nil, "", errors.New("no valid cookies found") + } + return mapCookiesToSlice(merged), "shared store", nil +} + +func mapCookiesToSlice(values map[string]*http.Cookie) []*http.Cookie { + if len(values) == 0 { + return nil + } + out := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + out = append(out, cookie) + } + return out +} + +func arSearchQuery(meta api.DuplicateSubject) string { + title := strings.TrimSpace(meta.Release.Title) + if title == "" && meta.ProviderMetadata.TMDB != nil { + title = strings.TrimSpace(meta.ProviderMetadata.TMDB.Title) + } + if title == "" && meta.ProviderMetadata.IMDB != nil { + title = strings.TrimSpace(meta.ProviderMetadata.IMDB.Title) + } + if title == "" { + title = strings.TrimSpace(meta.ReleaseName) + } + if title == "" { + return "" + } + + year := meta.Release.Year + if year == 0 && meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0 { + year = meta.ProviderMetadata.TMDB.Year + } + if year == 0 && meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0 { + year = meta.ProviderMetadata.IMDB.Year + } + if year > 0 { + return strings.TrimSpace(title + " " + strconv.Itoa(year)) + } + return title +} + +type arResponse struct { + Status string `json:"status"` + Response struct { + Results []struct { + GroupName string `json:"groupName"` + Size int64 `json:"size"` + FileCount int `json:"fileCount"` + GroupID int64 `json:"groupId"` + TorrentID int64 `json:"torrentId"` + } `json:"results"` + } `json:"response"` +} diff --git a/internal/trackers/impl/standalone/ar/dupe_test.go b/internal/trackers/impl/standalone/ar/dupe_test.go new file mode 100644 index 00000000..dba0c01c --- /dev/null +++ b/internal/trackers/impl/standalone/ar/dupe_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +type arRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f arRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestARHandlerSearchParsesResultsWithCookieFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cookieDir := filepath.Join(tmpDir, "cookies") + if err := os.MkdirAll(cookieDir, 0o755); err != nil { + t.Fatalf("mkdir cookies: %v", err) + } + cookiePath := filepath.Join(cookieDir, "AR.txt") + cookieText := `# Netscape HTTP Cookie File +.alpharatio.cc TRUE / TRUE 2147483647 session abc123 +` + if err := os.WriteFile(cookiePath, []byte(cookieText), 0o644); err != nil { + t.Fatalf("write cookies: %v", err) + } + + client := &http.Client{ + Transport: arRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + if got := req.URL.String(); !strings.HasPrefix(got, "https://alpharatio.cc/ajax.php?") { + t.Fatalf("unexpected request url %q", got) + } + query := req.URL.Query() + if got := query.Get("action"); got != "browse" { + t.Fatalf("expected action=browse, got %q", got) + } + if got := query.Get("searchstr"); got != "Movie Title 2023" { + t.Fatalf("expected searchstr to include title+year, got %q", got) + } + if got := req.Header.Get("User-Agent"); got == "" { + t.Fatalf("expected User-Agent header") + } + if raw := req.Header.Get("Cookie"); !strings.Contains(raw, "session=abc123") { + t.Fatal("expected cookie header to include session token") + } + + body := `{"status":"success","response":{"results":[{"groupName":"Movie.Title.2023.1080p.BluRay-GRP","size":123456789,"fileCount":1,"groupId":44,"torrentId":55}]}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "AR", + config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmpDir, "ua.db")}, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{Release: api.ReleaseInfo{Title: "Movie Title", Year: 2023}} + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + entry := entries[0] + if entry.Name != "Movie.Title.2023.1080p.BluRay-GRP" { + t.Fatalf("unexpected name %q", entry.Name) + } + if entry.FileCount != 1 { + t.Fatalf("expected file_count=1, got %d", entry.FileCount) + } + if !entry.SizeKnown || entry.SizeBytes != 123456789 { + t.Fatalf("unexpected size known=%t size=%d", entry.SizeKnown, entry.SizeBytes) + } + if len(entry.Files) != 1 || entry.Files[0] != entry.Name { + t.Fatalf("expected files to contain group name, got %#v", entry.Files) + } + if entry.ID != "55" { + t.Fatalf("expected ID=55, got %q", entry.ID) + } + if entry.Link != "https://alpharatio.cc/torrents.php?id=44&torrentid=55" { + t.Fatalf("unexpected link %q", entry.Link) + } + if entry.Download != "https://alpharatio.cc/torrents.php?action=download&id=55" { + t.Fatalf("unexpected download %q", entry.Download) + } +} + +func TestARHandlerMissingCookieFileReturnsSkipNote(t *testing.T) { + t.Parallel() + + handler := dupe.NewAdapter(New(), "AR", + config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(t.TempDir(), "ua.db")}, + }, &http.Client{}, api.NopLogger{}) + meta := api.DuplicateSubject{Release: api.ReleaseInfo{Title: "Movie Title", Year: 2023}} + + result := handler.Search(context.Background(), meta) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunMissingCredentials { + t.Fatalf("unexpected result disposition=%v code=%q", result.Disposition(), result.Code()) + } +} diff --git a/internal/trackers/impl/standalone/ar/profile.go b/internal/trackers/impl/standalone/ar/profile.go new file mode 100644 index 00000000..a5fb3a3c --- /dev/null +++ b/internal/trackers/impl/standalone/ar/profile.go @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns AR identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "AR", + BaseURL: arBaseURL, + DescriptionGroup: "ar", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{ + { + Scope: trackers.MetadataScopeMovie, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }, + { + Scope: trackers.MetadataScopeTV, + AnyOf: []trackers.MetadataField{ + trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB, trackers.MetadataFieldTVDB, + }, + Disposition: api.RuleDispositionStrict, + }, + {Scope: trackers.MetadataScopeAny, AnyOf: []trackers.MetadataField{trackers.MetadataFieldPoster}}, + }, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: arSourceFlag}, + DupePolicy: &trackers.DupePolicy{ContainsFilenameMatch: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"tracker.alpharatio"}}, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "AR", + DisplayName: "AR", + AuthKind: "cookies_login", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + }, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, _ api.TrackerAuthLoginRequest) error { + _, _, err := resolveSession(ctx, cfg, dbPath, nil) + return err + }, + AuthStateManager: trackerauth.NewKeyedFileStateManager("AR", arAuthKeyKey, arAuthFile), + } +} + +// New returns a fresh AR definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/ar/upload.go b/internal/trackers/impl/standalone/ar/upload.go new file mode 100644 index 00000000..2a85f30a --- /dev/null +++ b/internal/trackers/impl/standalone/ar/upload.go @@ -0,0 +1,1057 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + arBaseURL = "https://alpharatio.cc" + arUploadURL = arBaseURL + "/upload.php" + arLoginURL = arBaseURL + "/login.php" + arBrowseURL = arBaseURL + "/torrents.php" + arAuthFile = "AR_auth.txt" + arAuthKeyKey = "auth_key" + arUserAgent = "upbrr" + arSourceFlag = "AlphaRatio" +) + +var ( + arLoginFailurePattern = regexp.MustCompile(`login\.php\?act=recover|Forgot your password`) + arURLPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)(?:&torrentid=(\d+))?`) + arDownloadPattern = regexp.MustCompile(`torrents\.php\?action=download&id=(\d+)`) +) + +type uploadState struct { + torrentPath string + description string + fields map[string]string + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, client, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state, req.Meta) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: AR %s", state.blockedReason) + } + + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "AR") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, client, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + client *http.Client, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, arUploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: AR request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", arUserAgent) + + resp, err := client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: AR upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + bodyBytes, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: AR read upload response: %w", err) + } + groupID, torrentID := parseUploadIDs(finalURL, string(bodyBytes)) + if resp.StatusCode == http.StatusOK && groupID != "" { + torrentURL := buildTorrentURL(groupID, torrentID) + downloadURL := buildDownloadURL(torrentID, torrentURL) + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, arSourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + id := metautil.FirstNonEmptyTrimmed(torrentID, groupID) + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "AR", + TorrentID: id, + DownloadURL: downloadURL, + TorrentURL: torrentURL, + TorrentPath: artifactPath, + }}, + }, nil + } + + failurePath := "" + if pathValue, pathErr := resolveFailurePath(req.Meta, req.Runtime.DBPath); pathErr == nil { + failurePath = pathValue + redactedBody := []byte(redaction.RedactValue(string(responsePreview), nil)) + _ = os.WriteFile(failurePath, redactedBody, 0o600) + } + if failurePath != "" { + return api.UploadSummary{}, fmt.Errorf( + "%w failure=%s", + commonhttp.UploadHTTPErrorWithURL("AR", resp.StatusCode, finalURL, responsePreview), + failurePath, + ) + } + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("AR", resp.StatusCode, finalURL, responsePreview) +} + +func buildUploadPreview(state uploadState, meta api.UploadSubject) api.TrackerDryRunEntry { + fields := maps.Clone(state.fields) + if _, ok := fields["auth"]; ok { + fields["auth"] = "[redacted]" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "AR", + BlockedReason: state.blockedReason, + ReleaseName: resolveARName(meta), + DescriptionGroup: "ar", + Description: state.description, + Endpoint: arUploadURL, + Payload: fields, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, *http.Client, error) { + select { + case <-ctx.Done(): + return uploadState{}, nil, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + var assets trackers.DescriptionAssets + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + } + description := buildDescription(req.Meta, req.Runtime.DBPath, assets) + + fields := map[string]string{ + "submit": "true", + "type": resolveTypeID(req.Meta), + "title": resolveARName(req.Meta), + "tags": resolveTags(req.Meta), + "image": resolvePoster(req.Meta), + "desc": description, + } + state := uploadState{ + torrentPath: torrentPath, + description: description, + fields: fields, + } + if strings.TrimSpace(fields["image"]) == "" { + state.blockedReason = "missing poster URL" + } + + if dryRun { + if authProblem := dryRunAuthProblem(ctx, req.TrackerConfig, req.Runtime.DBPath); authProblem != "" && state.blockedReason == "" { + state.blockedReason = authProblem + } + fields["auth"] = "dry-run-auth" + return state, nil, nil + } + + client, authKey, err := resolveSession(ctx, req.TrackerConfig, req.Runtime.DBPath, req.Logger) + if err != nil { + return uploadState{}, nil, err + } + fields["auth"] = authKey + return state, client, nil +} + +func buildDescription(meta api.UploadSubject, dbPath string, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + + var parts []string + title := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.ReleaseName), strings.TrimSpace(meta.Release.Title), pathutil.Base(meta.SourcePath)) + parts = append(parts, fmt.Sprintf("[color=green][size=6]%s[/size][/color]", title)) + + if links := buildDatabaseLinks(meta); links != "" { + parts = append(parts, "[color=red][size=4]Links[/size][/color]\n"+links) + } + + mediaLabel := "MEDIAINFO" + var mediaText string + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + mediaLabel = "BDINFO" + mediaText, _ = readBDSummary(meta, dbPath) + case "DVD": + mediaText = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.DVDVOBMediaInfoText), readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath))) + default: + mediaText = readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath)) + } + if strings.TrimSpace(mediaText) != "" { + parts = append(parts, fmt.Sprintf("[color=red][size=4]%s[/size][/color]\n[hide][code]%s[/code][/hide]", mediaLabel, strings.TrimSpace(mediaText))) + } + + if overview := resolveOverview(meta); overview != "" { + parts = append(parts, "[color=red][size=4]PLOT[/size][/color]\n"+overview) + } + if genres := resolveGenres(meta); genres != "" { + parts = append(parts, "[color=red][size=4]Genres[/size][/color]\n"+genres) + } + if screenshots := buildScreenshotSection(assets.Screenshots); screenshots != "" { + parts = append(parts, "[color=red][size=4]Screenshots[/size][/color]\n"+screenshots) + } + if youtube := resolveYouTube(meta); youtube != "" { + parts = append(parts, "[color=red][size=4]Youtube[/size][/color]\n"+youtube) + } + if notes := cleanNotes(assets.Description); notes != "" { + parts = append(parts, "[color=red][size=4]Notes[/size][/color]\n"+notes) + } + + return strings.TrimSpace(strings.Join(parts, "\n\n")) +} + +func buildDatabaseLinks(meta api.UploadSubject) string { + links := make([]string, 0, 5) + if imdb := resolveIMDbURL(meta); imdb != "" { + links = append(links, imdb) + } + category, categoryErr := meta.Identity.RequireCategory() + if id := meta.Identity.TMDBID; id > 0 && categoryErr == nil { + links = append(links, fmt.Sprintf("https://www.themoviedb.org/%s/%d", category, id)) + } + if isTVDBCategory(meta) && meta.Identity.TVDBID > 0 { + id := meta.Identity.TVDBID + links = append(links, fmt.Sprintf("https://www.thetvdb.com/?id=%d&tab=series", id)) + } + if id := meta.Identity.TVmazeID; id > 0 { + links = append(links, fmt.Sprintf("https://www.tvmaze.com/shows/%d", id)) + } + if meta.Identity.MALID > 0 { + links = append(links, fmt.Sprintf("https://myanimelist.net/anime/%d", meta.Identity.MALID)) + } + return strings.Join(links, "\n") +} + +// isTVDBCategory reports whether AR descriptions may include a TVDB series link. +// Canonical movie identity suppresses TVDB links even when episode facts exist. +func isTVDBCategory(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func buildScreenshotSection(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := make([]string, 0, len(images)) + for _, image := range images { + if strings.TrimSpace(image.RawURL) == "" || strings.TrimSpace(image.ImgURL) == "" { + continue + } + parts = append(parts, "[url="+image.RawURL+"][img]"+image.ImgURL+"[/img][/url]") + } + if len(parts) == 0 { + return "" + } + return "[align=center]" + strings.Join(parts, "") + "[/align]" +} + +func cleanNotes(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + replacer := strings.NewReplacer("\r\n", "\n", "\r", "\n") + trimmed = replacer.Replace(trimmed) + sceneBlocks := []*regexp.Regexp{ + regexp.MustCompile(`(?is)\[center\]\[spoiler=Scene NFO:\].*?\[/center\]`), + regexp.MustCompile(`(?is)\[center\]\[spoiler=FraMeSToR NFO:\].*?\[/center\]`), + regexp.MustCompile(`(?is)\[color=red\]\[size=4\]Screenshots\[/size\]\[/color\]\s*\[align=center\].*?\[/align\]`), + regexp.MustCompile(`(?is)\[(?:right|align=right)\]\s*\[url=https://github\.com/(?:Audionut|autobrr)/upbrr\].*?\[/url\]\s*\[/(?:right|align)\]`), + } + for _, pattern := range sceneBlocks { + trimmed = pattern.ReplaceAllString(trimmed, "") + } + return strings.TrimSpace(trimmed) +} + +func resolveTypeID(meta api.UploadSubject) string { + genres := strings.ToLower(resolveGenres(meta) + " " + resolveKeywords(meta)) + adultKeywords := []string{"xxx", "erotic", "porn", "adult", "orgy"} + if (strings.EqualFold(meta.Type, "DISC") || strings.EqualFold(meta.Type, "REMUX")) && strings.EqualFold(meta.Source, "Blu-ray") { + return "14" + } + if meta.Anime { + if isSD(meta.Release.Resolution) { + return "15" + } + return "16" + } + if strings.EqualFold(string(meta.Identity.Category), "TV") { + if meta.TVPack { + if isSD(meta.Release.Resolution) { + return "4" + } + if isHighTier(meta.Release.Resolution) { + return "6" + } + return "5" + } + if isSD(meta.Release.Resolution) { + return "0" + } + if isHighTier(meta.Release.Resolution) { + return "2" + } + return "1" + } + if isSD(meta.Release.Resolution) { + return "7" + } + for _, keyword := range adultKeywords { + if strings.Contains(genres, keyword) { + return "13" + } + } + if isHighTier(meta.Release.Resolution) { + return "9" + } + return "8" +} + +func resolveARName(meta api.UploadSubject) string { + if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { + return strings.TrimSpace(meta.SceneName) + } + name := paths.ReleaseTempBaseFor(meta.SourcePath, meta.Release) + if ext := strings.TrimSpace(filepath.Ext(name)); ext != "" { + name = strings.TrimSuffix(name, ext) + } + replacer := strings.NewReplacer( + "_", ".", " ", ".", "'", "", ":", "", "(", ".", ")", ".", "[", ".", "]", ".", "{", ".", "}", ".", + ) + name = replacer.Replace(strings.TrimSpace(name)) + name = collapseDots(name) + + tagLower := strings.ToLower(strings.TrimSpace(meta.Tag)) + invalidTags := []string{"nogrp", "nogroup", "unknown", "-unk-"} + if tagLower == "" || containsAny(tagLower, invalidTags) { + for _, invalid := range invalidTags { + name = regexp.MustCompile(`(?i)-?`+regexp.QuoteMeta(invalid)).ReplaceAllString(name, "") + } + name = strings.Trim(strings.TrimSpace(name), ".-") + if name == "" { + name = "release" + } + return name + "-NoGRP" + } + return name +} + +func resolveTags(meta api.UploadSubject) string { + values := make([]string, 0, 8) + if meta.Identity.IMDBID > 0 { + values = append(values, "tt"+strconv.Itoa(meta.Identity.IMDBID)) + } + for value := range strings.SplitSeq(resolveGenres(meta), ",") { + for sub := range strings.SplitSeq(value, "&") { + tag := strings.TrimSpace(collapseDots(sub)) + if tag == "" { + continue + } + values = append(values, tag) + } + } + return strings.Join(uniqueStrings(values), ", ") +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + if meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) != "" { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + } + if meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Poster) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TVDB.Poster) + } + if meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster) + } + return "" +} + +func resolveOverview(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Overview) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.Overview) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Summary) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Summary) + default: + return strings.TrimSpace(meta.EpisodeOverview) + } +} + +func resolveGenres(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Genres) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.Genres) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Genres) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Genres) + default: + return strings.TrimSpace(meta.Release.Genre) + } +} + +func resolveKeywords(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) + } + return "" +} + +func resolveYouTube(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + return "" +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) != "" { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func dryRunAuthProblem(ctx context.Context, cfg config.TrackerConfig, dbPath string) string { + if _, err := os.Stat(authPath(dbPath)); err == nil { + return "" + } + if cookies, err := loadCookies(ctx, dbPath); err == nil && len(cookies) > 0 { + return "" + } + if strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "" { + return "" + } + return "missing valid AR cookies/auth and username/password are not configured" +} + +// resolveSession returns an authenticated AR client and auth key from stored +// cookies/auth state or by logging in with configured credentials. +func resolveSession(ctx context.Context, cfg config.TrackerConfig, dbPath string, logger api.Logger) (*http.Client, string, error) { + if logger == nil { + logger = api.NopLogger{} + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, "", fmt.Errorf("trackers: AR create cookie jar: %w", err) + } + base, _ := url.Parse(arBaseURL + "/") + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + if cookies, err := loadCookies(ctx, dbPath); err == nil && len(cookies) > 0 { + jar.SetCookies(base, cookies) + authKey, valid, authErr := validateSession(ctx, client, dbPath) + if authErr == nil && valid { + return client, authKey, nil + } + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, "", errors.New("trackers: AR cookie invalid/missing and username/password not configured") + } + if err := ensureLoginPersistenceAvailable(dbPath); err != nil { + return nil, "", err + } + authKey, err := login(ctx, client, cfg, dbPath) + if err != nil { + return nil, "", err + } + if err := persistLoginAuth(ctx, dbPath, logger, jar.Cookies(base), authKey); err != nil { + return nil, "", err + } + return client, authKey, nil +} + +func persistLoginAuth(ctx context.Context, dbPath string, logger api.Logger, values []*http.Cookie, authKey string) error { + return persistLoginAuthWithWriter(ctx, dbPath, logger, values, authKey, writeAuthKey) +} + +// persistLoginAuthWithWriter persists login cookies before the encrypted AR +// auth key so cookie persistence failures cannot commit a new key alone. If +// auth key persistence fails after cookies were saved, it restores the previous +// cookie state before returning the auth key error. +func persistLoginAuthWithWriter( + ctx context.Context, + dbPath string, + logger api.Logger, + values []*http.Cookie, + authKey string, + write func(context.Context, string, string) error, +) error { + previous, hadPrevious, err := snapshotLoginCookies(ctx, dbPath) + if err != nil { + return err + } + if err := persistLoginCookies(ctx, dbPath, logger, values); err != nil { + return err + } + if err := write(ctx, dbPath, authKey); err != nil { + if rollbackErr := restoreLoginCookies(context.WithoutCancel(ctx), dbPath, previous, hadPrevious); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("trackers: AR rollback login cookies: %w", rollbackErr)) + } + return err + } + return nil +} + +func snapshotLoginCookies(ctx context.Context, dbPath string) ([]*http.Cookie, bool, error) { + values, err := loadCookies(ctx, dbPath) + if err == nil { + return values, len(values) > 0, nil + } + if errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) || strings.Contains(strings.ToLower(err.Error()), "no cookies found") { + return nil, false, nil + } + return nil, false, err +} + +func restoreLoginCookies(ctx context.Context, dbPath string, previous []*http.Cookie, hadPrevious bool) error { + if hadPrevious { + return saveCookies(ctx, dbPath, previous) + } + if err := cookiepkg.DeleteTrackerCookies(ctx, dbPath, "AR"); err != nil { + return fmt.Errorf("trackers: AR delete cookies: %w", err) + } + return nil +} + +// persistLoginCookies saves encrypted cookies after login; persistence failures +// are returned so callers do not report login success without durable cookies. +func persistLoginCookies(ctx context.Context, dbPath string, logger api.Logger, values []*http.Cookie) error { + _ = logger + if len(usableHTTPCookies(values)) == 0 { + return errors.New("trackers: AR login returned no usable cookies") + } + if err := saveCookies(ctx, dbPath, values); err != nil { + return err + } + return nil +} + +func ensureLoginPersistenceAvailable(dbPath string) error { + if _, err := authmaterial.LoadFromDBPath(dbPath); err != nil { + if errors.Is(err, authmaterial.ErrUnavailable) { + return fmt.Errorf("trackers: AR encrypted auth storage unavailable before credential login: %w", cookiepkg.ErrAuthHelperUnavailable) + } + return fmt.Errorf("trackers: AR check encrypted auth storage before credential login: %w", err) + } + return nil +} + +// validateSession checks the current AR session and refreshes encrypted auth +// state when the response exposes an auth key. +func validateSession(ctx context.Context, client *http.Client, dbPath string) (string, bool, error) { + return validateSessionWithAuthKeyPersistence(ctx, client, dbPath, true) +} + +// validateSessionAfterLogin checks the just-authenticated AR session without +// persisting the auth key before login cookies are durably saved. +func validateSessionAfterLogin(ctx context.Context, client *http.Client, dbPath string) (string, bool, error) { + return validateSessionWithAuthKeyPersistence(ctx, client, dbPath, false) +} + +func validateSessionWithAuthKeyPersistence(ctx context.Context, client *http.Client, dbPath string, persistAuthKey bool) (string, bool, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, arBrowseURL, nil) + if err != nil { + return "", false, fmt.Errorf("trackers: AR session validation request build: %w", err) + } + httpReq.Header.Set("User-Agent", arUserAgent) + resp, err := client.Do(httpReq) + if err != nil { + return "", false, fmt.Errorf("trackers: AR session validation request: %w", err) + } + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", false, fmt.Errorf("trackers: AR read session response: %w", err) + } + body := string(bodyBytes) + if resp.StatusCode != http.StatusOK || arLoginFailurePattern.MatchString(body) { + return "", false, nil + } + authKey := extractAuthKey(body) + if authKey == "" { + if authKey := readAuthKey(ctx, dbPath); authKey != "" { + return authKey, true, nil + } + return "", false, nil + } + if !persistAuthKey { + return authKey, true, nil + } + if err := writeAuthKey(ctx, dbPath, authKey); err != nil { + return "", false, err + } + return authKey, true, nil +} + +func login(ctx context.Context, client *http.Client, cfg config.TrackerConfig, dbPath string) (string, error) { + values := url.Values{ + "username": {strings.TrimSpace(cfg.Username)}, + "password": {strings.TrimSpace(cfg.Password)}, + "keeplogged": {"1"}, + "login": {"Login"}, + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, arLoginURL, strings.NewReader(values.Encode())) + if err != nil { + return "", fmt.Errorf("trackers: AR login request build: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("User-Agent", arUserAgent) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("trackers: AR login request: %w", err) + } + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("trackers: AR read login response: %w", err) + } + body := string(bodyBytes) + if resp.StatusCode != http.StatusOK || arLoginFailurePattern.MatchString(body) { + return "", errors.New("trackers: AR login failed") + } + authKey := extractAuthKey(body) + if authKey != "" { + return authKey, nil + } + authKey, valid, err := validateSessionAfterLogin(ctx, client, dbPath) + if err != nil { + return "", err + } + if !valid || authKey == "" { + return "", errors.New("trackers: AR auth key not found after login") + } + return authKey, nil +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + file, err := os.Open(strings.TrimSpace(torrentPath)) + if err != nil { + return nil, "", fmt.Errorf("trackers: AR open torrent file: %w", err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return nil, "", fmt.Errorf("trackers: AR write multipart field %q: %w", key, err) + } + } + part, err := writer.CreateFormFile("file_input", filepath.Base(torrentPath)) + if err != nil { + return nil, "", fmt.Errorf("trackers: AR create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, "", fmt.Errorf("trackers: AR copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: AR close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func parseUploadIDs(finalURL string, body string) (string, string) { + if matches := arURLPattern.FindStringSubmatch(finalURL); len(matches) >= 2 { + return strings.TrimSpace(matches[1]), strings.TrimSpace(matchAt(matches, 2)) + } + if matches := arURLPattern.FindStringSubmatch(body); len(matches) >= 2 { + return strings.TrimSpace(matches[1]), strings.TrimSpace(matchAt(matches, 2)) + } + torrentID := "" + if matches := arDownloadPattern.FindStringSubmatch(body); len(matches) == 2 { + torrentID = strings.TrimSpace(matches[1]) + } + return "", torrentID +} + +func buildTorrentURL(groupID string, torrentID string) string { + if groupID == "" { + return "" + } + if torrentID != "" { + return arBaseURL + "/torrents.php?id=" + url.QueryEscape(groupID) + "&torrentid=" + url.QueryEscape(torrentID) + } + return arBaseURL + "/torrents.php?id=" + url.QueryEscape(groupID) +} + +func buildDownloadURL(torrentID string, fallback string) string { + if torrentID == "" { + return fallback + } + return arBaseURL + "/torrents.php?action=download&id=" + url.QueryEscape(torrentID) +} + +func readBDSummary(meta api.UploadSubject, dbPath string) (string, error) { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return readTextFile(paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists))) +} + +func resolveFailurePath(meta api.UploadSubject, dbPath string) (string, error) { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return filepath.Join(tmpDir, "[AR]upload_failure.html"), nil +} + +func authPath(dbPath string) string { + if strings.TrimSpace(dbPath) == "" { + return "" + } + path, err := db.CookiePath(dbPath, arAuthFile) + if err != nil { + return "" + } + return path +} + +// readAuthKey prefers encrypted AR auth state and falls back to the legacy +// plaintext auth key file. +func readAuthKey(ctx context.Context, dbPath string) string { + if authKey, err := trackerauth.LoadAuthState(ctx, dbPath, "AR", arAuthKeyKey); err == nil && strings.TrimSpace(authKey) != "" { + return strings.TrimSpace(authKey) + } + payload, err := os.ReadFile(authPath(dbPath)) + if err != nil { + return "" + } + return strings.TrimSpace(string(payload)) +} + +// writeAuthKey persists AR auth keys only to encrypted tracker auth state and +// deletes any legacy plaintext key after a successful write. If legacy cleanup +// fails, the encrypted write is rolled back so callers never commit split auth +// state. Existing legacy installs without web auth keep using the legacy path. +func writeAuthKey(ctx context.Context, dbPath string, authKey string) error { + authKey = strings.TrimSpace(authKey) + if authKey == "" { + return nil + } + previous, err := snapshotEncryptedAuthKey(ctx, dbPath) + if err != nil { + return err + } + if !previous.available { + return writeLegacyAuthKeyIfPresent(dbPath, authKey, previous.err) + } + if err := trackerauth.SaveAuthState(ctx, dbPath, "AR", arAuthKeyKey, authKey); err != nil { + return fmt.Errorf("trackers: AR write encrypted auth key: %w", err) + } + if legacyPath := authPath(dbPath); legacyPath != "" { + if err := os.Remove(legacyPath); err != nil && !errors.Is(err, os.ErrNotExist) { + if rollbackErr := restoreEncryptedAuthKey(context.WithoutCancel(ctx), dbPath, previous); rollbackErr != nil { + return errors.Join( + fmt.Errorf("trackers: AR remove legacy auth key: %w", err), + fmt.Errorf("trackers: AR rollback encrypted auth key: %w", rollbackErr), + ) + } + return fmt.Errorf("trackers: AR remove legacy auth key: %w", err) + } + } + return nil +} + +type encryptedAuthKeySnapshot struct { + value string + existed bool + available bool + err error +} + +func snapshotEncryptedAuthKey(ctx context.Context, dbPath string) (encryptedAuthKeySnapshot, error) { + value, err := trackerauth.LoadAuthState(ctx, dbPath, "AR", arAuthKeyKey) + if err == nil { + return encryptedAuthKeySnapshot{ + value: value, + existed: true, + available: true, + }, nil + } + if errors.Is(err, trackerauth.ErrAuthStateNotFound) { + return encryptedAuthKeySnapshot{available: true}, nil + } + if errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { + return encryptedAuthKeySnapshot{available: false, err: err}, nil + } + return encryptedAuthKeySnapshot{}, fmt.Errorf("trackers: AR snapshot encrypted auth key: %w", err) +} + +func restoreEncryptedAuthKey(ctx context.Context, dbPath string, previous encryptedAuthKeySnapshot) error { + if previous.existed { + if err := trackerauth.SaveAuthState(ctx, dbPath, "AR", arAuthKeyKey, previous.value); err != nil { + return fmt.Errorf("trackers: AR restore encrypted auth key: %w", err) + } + return nil + } + err := trackerauth.DeleteAuthState(ctx, dbPath, "AR", arAuthKeyKey) + if errors.Is(err, trackerauth.ErrAuthStateNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("trackers: AR remove restored encrypted auth key: %w", err) + } + return nil +} + +func writeLegacyAuthKeyIfPresent(dbPath string, authKey string, encryptedErr error) error { + legacyPath := authPath(dbPath) + if legacyPath == "" { + return fmt.Errorf("trackers: AR write encrypted auth key: %w", encryptedErr) + } + info, statErr := os.Stat(legacyPath) + if statErr != nil { + if errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("trackers: AR write encrypted auth key: %w", encryptedErr) + } + return fmt.Errorf("trackers: AR stat legacy auth key: %w", statErr) + } + if info.IsDir() { + return errors.New("trackers: AR legacy auth key path is a directory") + } + if err := os.WriteFile(legacyPath, []byte(authKey), 0o600); err != nil { + return fmt.Errorf("trackers: AR write legacy auth key: %w", err) + } + return nil +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookiepkg.LoadTrackerHTTPCookies(ctx, dbPath, "AR", "alpharatio.cc") + if err != nil { + return values, fmt.Errorf("trackers: AR load cookies: %w", err) + } + return values, nil +} + +func saveCookies(ctx context.Context, dbPath string, values []*http.Cookie) error { + valid := usableHTTPCookies(values) + if len(valid) == 0 { + return errors.New("trackers: AR login returned no usable cookies") + } + if err := cookiepkg.SaveTrackerHTTPCookies(ctx, dbPath, "AR", valid); err != nil { + return fmt.Errorf("trackers: AR save cookies: %w", err) + } + return nil +} + +func usableHTTPCookies(values []*http.Cookie) []*http.Cookie { + out := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { + continue + } + out = append(out, cookie) + } + return out +} + +func extractAuthKey(body string) string { + tokenizer := html.NewTokenizer(strings.NewReader(body)) + for { + //nolint:exhaustive // We only inspect tokens relevant to extracting the auth key. + switch tokenizer.Next() { + case html.ErrorToken: + return "" + case html.StartTagToken, html.SelfClosingTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "a") { + continue + } + href := "" + for _, attr := range token.Attr { + if strings.EqualFold(attr.Key, "href") { + href = attr.Val + break + } + } + if href == "" || !strings.Contains(href, "auth=") { + continue + } + parsed, err := url.Parse(href) + if err != nil { + continue + } + authKey := strings.TrimSpace(parsed.Query().Get("auth")) + if authKey != "" { + return authKey + } + } + } +} + +func readTextFile(path string) (string, error) { + payload, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return "", fmt.Errorf("trackers: AR read text file: %w", err) + } + return string(payload), nil +} + +func readTextFileNoErr(path string) string { + payload, err := readTextFile(path) + if err != nil { + return "" + } + return payload +} + +func isSD(resolution string) bool { + value := strings.ToLower(strings.TrimSpace(resolution)) + return strings.Contains(value, "576") || strings.Contains(value, "480") +} + +func isHighTier(resolution string) bool { + value := strings.ToLower(strings.TrimSpace(resolution)) + return strings.Contains(value, "2160") || strings.Contains(value, "4320") || strings.Contains(value, "8640") +} + +func collapseDots(value string) string { + cleaned := strings.TrimSpace(value) + for strings.Contains(cleaned, "..") { + cleaned = strings.ReplaceAll(cleaned, "..", ".") + } + return strings.Trim(cleaned, ".") +} + +func containsAny(value string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func matchAt(values []string, idx int) string { + if idx < len(values) { + return values[idx] + } + return "" +} diff --git a/internal/trackers/impl/standalone/ar/upload_test.go b/internal/trackers/impl/standalone/ar/upload_test.go new file mode 100644 index 00000000..ff2f2e25 --- /dev/null +++ b/internal/trackers/impl/standalone/ar/upload_test.go @@ -0,0 +1,462 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ar + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + servicedb "github.com/autobrr/upbrr/internal/services/db" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveARNameAddsNoGRP(t *testing.T) { + t.Parallel() + + got := resolveARName(api.UploadSubject{ + SourcePath: "C:/data/My Movie (2024).mkv", + Release: api.ReleaseInfo{Title: "My Movie", Year: 2024}, + }) + if got != "My.Movie.2024-NoGRP" { + t.Fatalf("unexpected AR name %q", got) + } +} + +func TestResolveARNameUsesSceneName(t *testing.T) { + t.Parallel() + + got := resolveARName(api.UploadSubject{ + Scene: true, + SceneName: "Scene.Release-GRP", + Tag: "-GRP", + }) + if got != "Scene.Release-GRP" { + t.Fatalf("expected scene name, got %q", got) + } +} + +type captureLogger struct { + warnings []string +} + +func (l *captureLogger) Tracef(string, ...any) {} +func (l *captureLogger) Debugf(string, ...any) {} +func (l *captureLogger) Infof(string, ...any) {} +func (l *captureLogger) Errorf(string, ...any) {} +func (l *captureLogger) Warnf(format string, _ ...any) { + l.warnings = append(l.warnings, strings.TrimSpace(format)) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func arTestResponse(req *http.Request, body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} + +func TestPersistLoginCookiesReturnsAuthHelperUnavailable(t *testing.T) { + t.Parallel() + + logger := &captureLogger{} + err := persistLoginCookies(context.Background(), filepath.Join(t.TempDir(), "upbrr.db"), logger, []*http.Cookie{{Name: "session", Value: "abc"}}) + if !errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { + t.Fatalf("expected auth helper unavailable error, got %v", err) + } + if len(logger.warnings) != 0 { + t.Fatalf("unexpected warning after hard persistence failure: %v", logger.warnings) + } +} + +func TestPersistLoginCookiesRejectsEmptyJarWithoutReplacingCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "AR", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + + err := persistLoginCookies(ctx, dbPath, api.NopLogger{}, nil) + if err == nil || !strings.Contains(err.Error(), "no usable cookies") { + t.Fatalf("expected empty login cookie error, got %v", err) + } + values, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "AR") + if err != nil { + t.Fatalf("LoadTrackerCookieMap: %v", err) + } + if values["session"] != "existing" { + t.Fatal("empty jar must preserve previous cookies") + } +} + +func TestEnsureLoginPersistenceAvailableRequiresWebAuth(t *testing.T) { + t.Parallel() + + err := ensureLoginPersistenceAvailable(filepath.Join(t.TempDir(), "upbrr.db")) + if !errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { + t.Fatalf("expected auth helper unavailable preflight, got %v", err) + } +} + +func TestWriteAuthKeyUsesEncryptedStateAndDeletesLegacyFile(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + legacyPath := authPath(dbPath) + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatalf("mkdir legacy dir: %v", err) + } + if err := os.WriteFile(legacyPath, []byte("legacy-key"), 0o600); err != nil { + t.Fatalf("seed legacy auth key: %v", err) + } + + if err := writeAuthKey(context.Background(), dbPath, "encrypted-key"); err != nil { + t.Fatalf("writeAuthKey: %v", err) + } + if got := readAuthKey(context.Background(), dbPath); got != "encrypted-key" { + t.Fatal("expected encrypted auth key") + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("expected legacy auth key removed, stat err=%v", err) + } +} + +func TestWriteAuthKeyRollsBackEncryptedStateOnLegacyDeleteFailure(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + legacyPath := authPath(dbPath) + if err := os.MkdirAll(filepath.Join(legacyPath, "blocked"), 0o755); err != nil { + t.Fatalf("seed legacy auth path as non-empty dir: %v", err) + } + + if err := writeAuthKey(context.Background(), dbPath, "encrypted-key"); err == nil { + t.Fatal("expected legacy cleanup failure") + } else if !strings.Contains(err.Error(), "remove legacy auth key") { + t.Fatalf("expected legacy cleanup error, got %v", err) + } + if _, err := trackerauth.LoadAuthState(context.Background(), dbPath, "AR", arAuthKeyKey); !errors.Is(err, trackerauth.ErrAuthStateNotFound) { + t.Fatalf("expected encrypted auth key rollback, got %v", err) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("expected failed legacy cleanup path to remain, stat err=%v", err) + } +} + +func TestWriteAuthKeyFallsBackToExistingLegacyFileWhenWebAuthUnavailable(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + legacyPath := authPath(dbPath) + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatalf("mkdir legacy dir: %v", err) + } + if err := os.WriteFile(legacyPath, []byte("legacy-key"), 0o600); err != nil { + t.Fatalf("seed legacy auth key: %v", err) + } + + if err := writeAuthKey(context.Background(), dbPath, "updated-legacy-key"); err != nil { + t.Fatalf("writeAuthKey: %v", err) + } + if got := readAuthKey(context.Background(), dbPath); got != "updated-legacy-key" { + t.Fatal("expected updated legacy auth key") + } +} + +func TestWriteAuthKeySucceedsWhenLegacyFileAbsent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + + if err := writeAuthKey(context.Background(), dbPath, "encrypted-key"); err != nil { + t.Fatalf("writeAuthKey: %v", err) + } + if got := readAuthKey(context.Background(), dbPath); got != "encrypted-key" { + t.Fatal("expected encrypted auth key") + } +} + +func TestWriteAuthKeyWithoutWebAuthReturnsPersistenceFailure(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := writeAuthKey(context.Background(), dbPath, "session-key"); !errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { + t.Fatalf("expected auth helper unavailable error, got %v", err) + } + if _, err := os.Stat(authPath(dbPath)); !os.IsNotExist(err) { + t.Fatalf("expected no plaintext auth key, stat err=%v", err) + } +} + +func TestPersistLoginAuthRestoresPreviousCookiesWhenAuthKeyWriteFails(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "AR", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + forcedErr := errors.New("forced auth key failure") + + err := persistLoginAuthWithWriter( + ctx, + dbPath, + api.NopLogger{}, + []*http.Cookie{{Name: "session", Value: "new"}}, + "new-auth-key", + func(context.Context, string, string) error { return forcedErr }, + ) + if !errors.Is(err, forcedErr) { + t.Fatalf("expected forced auth key failure, got %v", err) + } + values, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "AR") + if err != nil { + t.Fatalf("LoadTrackerCookieMap: %v", err) + } + if values["session"] != "existing" { + t.Fatal("expected previous cookies restored") + } +} + +func TestPersistLoginAuthRestoresPreviousCookiesWhenCallerContextCanceledDuringWrite(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + if err := cookiepkg.SaveTrackerCookieMap(context.Background(), dbPath, "AR", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + forcedErr := errors.New("forced auth key failure") + + err := persistLoginAuthWithWriter( + ctx, + dbPath, + api.NopLogger{}, + []*http.Cookie{{Name: "session", Value: "new"}}, + "new-auth-key", + func(context.Context, string, string) error { + cancel() + return forcedErr + }, + ) + if !errors.Is(err, forcedErr) { + t.Fatalf("expected forced auth key failure, got %v", err) + } + values, err := cookiepkg.LoadTrackerCookieMap(context.Background(), dbPath, "AR") + if err != nil { + t.Fatalf("LoadTrackerCookieMap: %v", err) + } + if values["session"] != "existing" { + t.Fatal("expected previous cookies restored after cancellation") + } +} + +func TestPersistLoginAuthRemovesNewCookiesWhenAuthKeyWriteFails(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + forcedErr := errors.New("forced auth key failure") + + err := persistLoginAuthWithWriter( + ctx, + dbPath, + api.NopLogger{}, + []*http.Cookie{{Name: "session", Value: "new"}}, + "new-auth-key", + func(context.Context, string, string) error { return forcedErr }, + ) + if !errors.Is(err, forcedErr) { + t.Fatalf("expected forced auth key failure, got %v", err) + } + if _, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "AR"); err == nil { + t.Fatal("expected rollback to remove newly persisted cookies") + } +} + +func TestLoginFallbackDoesNotPersistAuthKeyBeforeCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("cookiejar.New: %v", err) + } + client := &http.Client{ + Jar: jar, + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case arLoginURL: + return arTestResponse(req, "logged in without inline auth"), nil + case arBrowseURL: + return arTestResponse(req, `Download`), nil + default: + t.Fatalf("unexpected request URL %s", req.URL.String()) + return nil, nil + } + }), + } + + authKey, err := login(ctx, client, config.TrackerConfig{Username: "user", Password: "pass"}, dbPath) + if err != nil { + t.Fatalf("login: %v", err) + } + if authKey != "session-key" { + t.Fatalf("expected auth key from validation page, got %q", authKey) + } + if _, err := trackerauth.LoadAuthState(ctx, dbPath, "AR", arAuthKeyKey); !errors.Is(err, trackerauth.ErrAuthStateNotFound) { + t.Fatalf("login fallback must not persist auth key before cookie save, got %v", err) + } + + if err := persistLoginAuth(ctx, dbPath, api.NopLogger{}, nil, authKey); err == nil || !strings.Contains(err.Error(), "no usable cookies") { + t.Fatalf("expected cookie persistence failure, got %v", err) + } + if _, err := trackerauth.LoadAuthState(ctx, dbPath, "AR", arAuthKeyKey); !errors.Is(err, trackerauth.ErrAuthStateNotFound) { + t.Fatalf("cookie persistence failure must leave no new auth key, got %v", err) + } +} + +func TestLoginFallbackPrefersCurrentResponseKeyAndPreservesPreviousAuthKeyOnCookieFailure(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newARAuthTestDB(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + if err := trackerauth.SaveAuthState(ctx, dbPath, "AR", arAuthKeyKey, "previous-key"); err != nil { + t.Fatalf("SaveAuthState: %v", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("cookiejar.New: %v", err) + } + client := &http.Client{ + Jar: jar, + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case arLoginURL: + return arTestResponse(req, "logged in without inline auth"), nil + case arBrowseURL: + return arTestResponse(req, `Download`), nil + default: + t.Fatalf("unexpected request URL %s", req.URL.String()) + return nil, nil + } + }), + } + + authKey, err := login(ctx, client, config.TrackerConfig{Username: "user", Password: "pass"}, dbPath) + if err != nil { + t.Fatalf("login: %v", err) + } + if authKey != "new-key" { + t.Fatalf("expected current response auth key, got %q", authKey) + } + if err := persistLoginAuth(ctx, dbPath, api.NopLogger{}, nil, authKey); err == nil || !strings.Contains(err.Error(), "no usable cookies") { + t.Fatalf("expected cookie persistence failure, got %v", err) + } + got, err := trackerauth.LoadAuthState(ctx, dbPath, "AR", arAuthKeyKey) + if err != nil { + t.Fatalf("LoadAuthState: %v", err) + } + if got != "previous-key" { + t.Fatal("cookie persistence failure changed previous auth key") + } +} + +func newARAuthTestDB(t *testing.T) string { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + repo, err := servicedb.OpenWithLogger(dbPath, api.NopLogger{}) + if err != nil { + t.Fatalf("open repo: %v", err) + } + if err := repo.Migrate(); err != nil { + _ = repo.Close() + t.Fatalf("migrate repo: %v", err) + } + if err := repo.Close(); err != nil { + t.Fatalf("close repo: %v", err) + } + return dbPath +} + +func TestBuildDatabaseLinksSkipsTVDBForMovie(t *testing.T) { + t.Parallel() + + got := buildDatabaseLinks(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE", TVDBID: 456}, + }) + if strings.Contains(got, "thetvdb.com") { + t.Fatalf("did not expect tvdb link for movie description, got %q", got) + } +} + +func TestBuildDatabaseLinksIncludesTVDBForMediaInfoTV(t *testing.T) { + t.Parallel() + + got := buildDatabaseLinks(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 456}, + }) + if !strings.Contains(got, "thetvdb.com/?id=456") { + t.Fatalf("expected tvdb link for MediaInfo TV description, got %q", got) + } +} + +func TestBuildDatabaseLinksIncludesTVDBForTV(t *testing.T) { + t.Parallel() + + got := buildDatabaseLinks(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 456}, + }) + if !strings.Contains(got, "thetvdb.com/?id=456") { + t.Fatalf("expected tvdb link for TV description, got %q", got) + } +} diff --git a/internal/trackers/impl/asc/constants.go b/internal/trackers/impl/standalone/asc/constants.go similarity index 100% rename from internal/trackers/impl/asc/constants.go rename to internal/trackers/impl/standalone/asc/constants.go diff --git a/internal/trackers/impl/standalone/asc/definition.go b/internal/trackers/impl/standalone/asc/definition.go new file mode 100644 index 00000000..03ba7367 --- /dev/null +++ b/internal/trackers/impl/standalone/asc/definition.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets, req.TrackerConfig.CustomLayout) + return trackers.DescriptionResult{ + Group: "asc", + Description: strings.TrimSpace(description), + }, nil +} diff --git a/internal/trackers/impl/standalone/asc/definition_test.go b/internal/trackers/impl/standalone/asc/definition_test.go new file mode 100644 index 00000000..3501cf7a --- /dev/null +++ b/internal/trackers/impl/standalone/asc/definition_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func prepareDryRun(ctx context.Context, input trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + input.Intent = trackers.PreparationIntentDryRun + plan, failure := New().Prepare(ctx, input) + if failure != nil { + return api.TrackerDryRunEntry{}, failure + } + return plan.DryRun(), nil +} + +const testCookieFileName = "ASC.txt" + +func TestDefinitionBuildUploadDryRunBlockedWithoutCookies(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "release.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "ASC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "movie.mkv"), + TorrentPath: torrentPath, + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2024, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE", IMDBID: 1234567}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Poster: "https://img/poster.jpg", + Overview: "Overview", + Genres: "Drama", + }, + IMDB: &api.IMDBMetadata{IMDbIDText: "tt1234567"}, + }, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}, + }), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Status != "blocked" { + t.Fatalf("expected blocked status, got %q", entry.Status) + } +} + +func TestDefinitionBuildUploadDryRunQuestionnaireForMissingMetadata(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cookieDir := filepath.Join(tmp, "cookies") + if err := os.MkdirAll(cookieDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + content := "# Netscape HTTP Cookie File\n.cliente.amigos-share.club\tTRUE\t/\tTRUE\t0\tsession\tcookievalue\n" + if err := os.WriteFile(filepath.Join(cookieDir, testCookieFileName), []byte(content), 0o600); err != nil { + t.Fatalf("write cookie: %v", err) + } + torrentPath := filepath.Join(tmp, "release.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "ASC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "movie.mkv"), + TorrentPath: torrentPath, + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2024, + Resolution: "1080p", + }, + Identity: api.ExternalIdentity{Category: "MOVIE", IMDBID: 1234567}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Poster: "https://img/poster.jpg"}, + IMDB: &api.IMDBMetadata{IMDbIDText: "tt1234567"}, + }, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}, + }), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Questionnaire == nil { + t.Fatal("expected questionnaire") + } + if got := len(entry.Questionnaire.Fields); got != 2 { + t.Fatalf("expected 2 questionnaire fields, got %d", got) + } +} diff --git a/internal/trackers/impl/standalone/asc/description.go b/internal/trackers/impl/standalone/asc/description.go new file mode 100644 index 00000000..f4b0919d --- /dev/null +++ b/internal/trackers/impl/standalone/asc/description.go @@ -0,0 +1,742 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/metadata/tmdb" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +type layoutData struct { + Images map[string]string + Ratings []map[string]any +} + +type richCreditItem struct { + ID int `json:"id"` + Name string `json:"name"` + Character string `json:"character"` + ProfilePath string `json:"profile_path"` +} + +type richSeasonItem struct { + AirDate string `json:"air_date"` + EpisodeCount *int `json:"episode_count"` + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + SeasonNumber int `json:"season_number"` +} + +type richEpisodeDetails struct { + Name string `json:"name"` + Overview string `json:"overview"` + StillPath string `json:"still_path"` +} + +type richMediaResponse struct { + VoteAverage float64 `json:"vote_average"` + Homepage string `json:"homepage"` +} + +func tmdbCachePath(dbPath string, tmdbID int, suffix string) string { + if strings.TrimSpace(dbPath) == "" { + return "" + } + cacheRoot, err := db.Subdir(dbPath, "cache") + if err != nil { + return "" + } + return filepath.Join(cacheRoot, fmt.Sprintf("tmdb_localized_%d_%s.json", tmdbID, suffix)) +} + +func fetchRichMedia(ctx context.Context, client *tmdb.Client, tmdbID int, category string, cachePath string) (richMediaResponse, error) { + data, err := client.GetLocalizedData(ctx, tmdb.LocalizedDataInput{ + TMDBID: tmdbID, + Category: strings.ToLower(category), + DataType: "main", + CachePath: cachePath, + }) + if err != nil { + return richMediaResponse{}, fmt.Errorf("fetch rich media: %w", err) + } + var resp richMediaResponse + if vote, ok := data["vote_average"].(float64); ok { + resp.VoteAverage = vote + } + if homepage, ok := data["homepage"].(string); ok { + resp.Homepage = homepage + } + return resp, nil +} + +func fetchRichCredits(ctx context.Context, client *tmdb.Client, tmdbID int, category string, cachePath string) ([]richCreditItem, error) { + data, err := client.GetLocalizedData(ctx, tmdb.LocalizedDataInput{ + TMDBID: tmdbID, + Category: strings.ToLower(category), + DataType: "main", + AppendToResponse: "credits", + CachePath: cachePath, + }) + if err != nil { + return nil, fmt.Errorf("fetch rich credits: %w", err) + } + credits, ok := data["credits"].(map[string]any) + if !ok { + credits = data + } + castRaw, ok := credits["cast"].([]any) + if !ok { + return nil, errors.New("no cast found") + } + var cast []richCreditItem + for _, item := range castRaw { + m, ok := item.(map[string]any) + if !ok { + continue + } + var credit richCreditItem + if idVal, ok := m["id"].(float64); ok { + credit.ID = int(idVal) + } + if nameVal, ok := m["name"].(string); ok { + credit.Name = nameVal + } + if charVal, ok := m["character"].(string); ok { + credit.Character = charVal + } + if profileVal, ok := m["profile_path"].(string); ok { + credit.ProfilePath = profileVal + } + cast = append(cast, credit) + } + return cast, nil +} + +func fetchRichSeasons(ctx context.Context, client *tmdb.Client, tmdbID int, cachePath string) ([]richSeasonItem, error) { + data, err := client.GetLocalizedData(ctx, tmdb.LocalizedDataInput{ + TMDBID: tmdbID, + Category: "tv", + DataType: "main", + Language: "pt-BR", + CachePath: cachePath, + }) + if err != nil { + return nil, fmt.Errorf("fetch rich seasons: %w", err) + } + seasonsRaw, ok := data["seasons"].([]any) + if !ok { + return nil, errors.New("no seasons found") + } + var seasons []richSeasonItem + for _, item := range seasonsRaw { + m, ok := item.(map[string]any) + if !ok { + continue + } + var season richSeasonItem + if airVal, ok := m["air_date"].(string); ok { + season.AirDate = airVal + } + if countVal, ok := m["episode_count"].(float64); ok { + c := int(countVal) + season.EpisodeCount = &c + } + if idVal, ok := m["id"].(float64); ok { + season.ID = int(idVal) + } + if nameVal, ok := m["name"].(string); ok { + season.Name = nameVal + } + if overVal, ok := m["overview"].(string); ok { + season.Overview = overVal + } + if posterVal, ok := m["poster_path"].(string); ok { + season.PosterPath = posterVal + } + if numVal, ok := m["season_number"].(float64); ok { + season.SeasonNumber = int(numVal) + } + seasons = append(seasons, season) + } + return seasons, nil +} + +func fetchRichEpisode(ctx context.Context, client *tmdb.Client, tmdbID, season, episode int, cachePath string) (richEpisodeDetails, error) { + data, err := client.GetLocalizedData(ctx, tmdb.LocalizedDataInput{ + TMDBID: tmdbID, + Category: "tv", + DataType: "episode", + Season: season, + Episode: episode, + Language: "pt-BR", + CachePath: cachePath, + }) + if err != nil { + return richEpisodeDetails{}, fmt.Errorf("fetch rich episode: %w", err) + } + var ep richEpisodeDetails + if nameVal, ok := data["name"].(string); ok { + ep.Name = nameVal + } + if overVal, ok := data["overview"].(string); ok { + ep.Overview = overVal + } + if stillVal, ok := data["still_path"].(string); ok { + ep.StillPath = stillVal + } + return ep, nil +} + +func buildDescription(ctx context.Context, meta api.UploadSubject, cfg config.Config, assets trackers.DescriptionAssets, layoutID string) string { + if assets.Override && strings.TrimSpace(assets.Description) != "" { + return strings.TrimSpace(assets.Description) + } + layout, _ := fetchLayout(ctx, cfg.MainSettings.DBPath, meta, layoutID) + parts := []string{"[center]"} + + for idx := 1; idx <= 3; idx++ { + if image := layout.Images[fmt.Sprintf("BARRINHA_CUSTOM_T_%d", idx)]; image != "" { + parts = append(parts, formatImage(image)) + } + } + if image := layout.Images["BARRINHA_APRESENTA"]; image != "" { + parts = append(parts, formatImage(image)) + } + parts = append(parts, "[size=3]"+resolveUploadTitle(meta)+"[/size]") + + appendSection := func(key string, content string) { + if strings.TrimSpace(content) == "" { + return + } + if image := layout.Images[key]; image != "" { + parts = append(parts, formatImage(image)) + } + parts = append(parts, content) + } + + apiKey := strings.TrimSpace(cfg.MainSettings.TMDBAPI) + tmdbID := meta.Identity.TMDBID + + // TMDB Sub-queries + var richMedia *richMediaResponse + var richCast []richCreditItem + var richSeasons []richSeasonItem + var richEpisode *richEpisodeDetails + + if apiKey != "" && tmdbID > 0 { + tmdbClient := tmdb.NewClient(nil, nil, apiKey) + dbPath := cfg.MainSettings.DBPath + + if media, err := fetchRichMedia(ctx, tmdbClient, tmdbID, categoryOf(meta), tmdbCachePath(dbPath, tmdbID, "main")); err == nil { + richMedia = &media + } + if castList, err := fetchRichCredits(ctx, tmdbClient, tmdbID, categoryOf(meta), tmdbCachePath(dbPath, tmdbID, "credits")); err == nil { + richCast = castList + } + if categoryOf(meta) == "TV" { + if seasons, err := fetchRichSeasons(ctx, tmdbClient, tmdbID, tmdbCachePath(dbPath, tmdbID, "pt_seasons")); err == nil { + richSeasons = seasons + } + if meta.SeasonInt > 0 && meta.EpisodeInt > 0 { + suffix := fmt.Sprintf("ep_%d_%d", meta.SeasonInt, meta.EpisodeInt) + if ep, err := fetchRichEpisode(ctx, tmdbClient, tmdbID, meta.SeasonInt, meta.EpisodeInt, tmdbCachePath(dbPath, tmdbID, suffix)); err == nil { + richEpisode = &ep + } + } + } + } + + // 1. Poster + if poster := resolvePoster(meta); poster != "" { + poster = strings.ReplaceAll(poster, "/t/p/original/", "/t/p/w500/") + appendSection("BARRINHA_CAPA", formatImage(poster)) + } + + // 2. Overview + appendSection("BARRINHA_SINOPSE", resolveOverview(meta, standalone.QuestionnaireAnswers(meta, "ASC"))) + + // 3. Episode Specific Section + if categoryOf(meta) == "TV" && richEpisode != nil { + if richEpisode.Name != "" && richEpisode.Overview != "" { + parts = append(parts, fmt.Sprintf("[size=4][b]Episódio:[/b] %s[/size]", richEpisode.Name)) + if strings.TrimSpace(richEpisode.StillPath) != "" { + stillURL := "https://image.tmdb.org/t/p/w300" + strings.TrimSpace(richEpisode.StillPath) + parts = append(parts, formatImage(stillURL)) + } + parts = append(parts, richEpisode.Overview) + } + } + + // 4. Technical Sheet + appendSection("BARRINHA_FICHA_TECNICA", buildTechnicalSheet(meta, richMedia)) + + // 5. Production Companies + if prodComp := buildProductionCompanies(meta); prodComp != "" { + parts = append(parts, prodComp) + } + + // 6. Cast + appendSection("BARRINHA_ELENCO", buildCastSection(meta, richCast)) + + // 7. Seasons Section (TV Packs / TV Seasons list) + if categoryOf(meta) == "TV" && len(richSeasons) > 0 { + var seasonsContent []string + for _, s := range richSeasons { + seasonName := strings.TrimSpace(s.Name) + if seasonName == "" { + seasonName = fmt.Sprintf("Temporada %d", s.SeasonNumber) + } + posterTemp := "" + if strings.TrimSpace(s.PosterPath) != "" { + posterTemp = formatImage("https://image.tmdb.org/t/p/w185" + strings.TrimSpace(s.PosterPath)) + } + overviewTemp := "" + if strings.TrimSpace(s.Overview) != "" { + overviewTemp = "\n\nSinopse:\n" + strings.TrimSpace(s.Overview) + } + var innerContentParts []string + if s.AirDate != "" { + innerContentParts = append(innerContentParts, "Data: "+formatDate(s.AirDate)) + } + if s.EpisodeCount != nil { + innerContentParts = append(innerContentParts, fmt.Sprintf("Episódios: %d", *s.EpisodeCount)) + } + if posterTemp != "" { + innerContentParts = append(innerContentParts, posterTemp) + } + if overviewTemp != "" { + innerContentParts = append(innerContentParts, overviewTemp) + } + innerContent := strings.Join(innerContentParts, "\n") + seasonsContent = append(seasonsContent, fmt.Sprintf("\n[spoiler=%s]%s[/spoiler]\n", seasonName, innerContent)) + } + appendSection("BARRINHA_EPISODIOS", strings.Join(seasonsContent, "")) + } + + // 8. Ratings Section + var ratingsList []map[string]any + ratingsList = append(ratingsList, layout.Ratings...) + hasIMDbRating := false + hasTMDbRating := false + for _, r := range ratingsList { + source, _ := r["Source"].(string) + if source == "Internet Movie Database" { + hasIMDbRating = true + } + if source == "TMDb" { + hasTMDbRating = true + } + } + if !hasIMDbRating && meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Rating > 0 { + ratingsList = append(ratingsList, map[string]any{ + "Source": "Internet Movie Database", + "Value": fmt.Sprintf("%.1f/10", meta.ProviderMetadata.IMDB.Rating), + }) + } + if !hasTMDbRating && richMedia != nil && richMedia.VoteAverage > 0 { + ratingsList = append(ratingsList, map[string]any{ + "Source": "TMDb", + "Value": fmt.Sprintf("%.1f/10", richMedia.VoteAverage), + }) + } + + criticsKey := "BARRINHA_CRITICAS" //nolint:misspell + if categoryOf(meta) == "MOVIE" && layout.Images["BARRINHA_INFORMACOES"] != "" { + criticsKey = "BARRINHA_INFORMACOES" + } + appendSection(criticsKey, buildRatingsBBCode(meta, ratingsList)) + + // 9. MediaInfo/BDInfo + if media := buildMediaInfo(meta, cfg.MainSettings.DBPath); media != "" { + parts = append(parts, "[spoiler=Informações do Arquivo]\n[left][font=Courier New]"+media+"[/font][/left][/spoiler]") + } + if notes := sanitizeDescriptionNotes(assets.Description); notes != "" { + parts = append(parts, notes) + } + if customHeader := strings.TrimSpace(cfg.Description.CustomDescriptionHeader); customHeader != "" { + parts = append(parts, customHeader) + } + + for idx := 1; idx <= 3; idx++ { + if image := layout.Images[fmt.Sprintf("BARRINHA_CUSTOM_B_%d", idx)]; image != "" { + parts = append(parts, formatImage(image)) + } + } + parts = append(parts, "[/center]") + parts = append(parts, "[center][url=https://github.com/autobrr/upbrr]Upload realizado via upbrr[/url][/center]") + return strings.TrimSpace(strings.Join(filterEmpty(parts), "\n\n")) +} + +func fetchLayout(ctx context.Context, dbPath string, meta api.UploadSubject, layoutID string) (layoutData, error) { + cached, err := readLayoutCache(dbPath, layoutID) + if err == nil { + return cached, nil + } + cookies, _, err := LoadCookies(ctx, dbPath) + if err != nil { + return layoutData{}, err + } + form := url.Values{ + "imdb": {metautil.FirstNonEmptyTrimmed(resolveIMDbIDText(meta), "tt0013442")}, + "layout": {metautil.FirstNonEmptyTrimmed(strings.TrimSpace(layoutID), "2")}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/search.php", strings.NewReader(form.Encode())) + if err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC create layout request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) + if err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC fetch layout: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return layoutData{}, fmt.Errorf("layout fetch status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC read layout response: %w", err) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC unmarshal layout response: %s", redaction.RedactValue(err.Error(), nil)) + } + var raw map[string]any + if err := json.Unmarshal(payload["ASC"], &raw); err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC unmarshal layout data: %s", redaction.RedactValue(err.Error(), nil)) + } + layout := normalizeLayout(raw) + _ = writeLayoutCache(dbPath, layoutID, payload["ASC"]) + return layout, nil +} + +func normalizeLayout(raw map[string]any) layoutData { + layout := layoutData{Images: make(map[string]string)} + for key, value := range raw { + if strings.HasPrefix(key, "BARRINHA_") { + text := strings.TrimSpace(fmt.Sprint(value)) + if text != "" && text != "" { + layout.Images[key] = text + } + } + } + if ratingsVal, ok := raw["Ratings"]; ok { + if ratingsSlice, ok := ratingsVal.([]any); ok { + for _, r := range ratingsSlice { + if rMap, ok := r.(map[string]any); ok { + layout.Ratings = append(layout.Ratings, rMap) + } + } + } + } + return layout +} + +func readLayoutCache(dbPath string, layoutID string) (layoutData, error) { + payload, err := os.ReadFile(layoutCachePath(dbPath, layoutID)) + if err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC read layout cache: %w", err) + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return layoutData{}, fmt.Errorf("trackers: ASC unmarshal layout cache: %w", err) + } + return normalizeLayout(raw), nil +} + +func writeLayoutCache(dbPath string, layoutID string, payload []byte) error { + path := layoutCachePath(dbPath, layoutID) + if path == "" { + return errors.New("missing layout cache path") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("trackers: ASC create layout cache dir: %w", err) + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + return fmt.Errorf("trackers: ASC write layout cache: %w", err) + } + return nil +} + +func layoutCachePath(dbPath string, layoutID string) string { + if strings.TrimSpace(dbPath) == "" { + return "" + } + cacheRoot, err := db.Subdir(dbPath, "cache") + if err != nil { + return "" + } + return filepath.Join(cacheRoot, "asc_layout_"+metautil.FirstNonEmptyTrimmed(strings.TrimSpace(layoutID), "2")+".json") +} + +func buildTechnicalSheet(meta api.UploadSubject, richMedia *richMediaResponse) string { + items := make([]string, 0, 5) + if runtime := resolveRuntime(meta); runtime != "" { + items = append(items, "Duração: "+runtime) + } + if countries := resolveCountries(meta); countries != "" { + items = append(items, "País de Origem: "+countries) + } + if genres := resolveGenres(meta, standalone.QuestionnaireAnswers(meta, "ASC")); genres != "" { + items = append(items, "Gêneros: "+genres) + } + if releaseDate := resolveReleaseDate(meta); releaseDate != "" { + items = append(items, "Data de Lançamento: "+formatDate(releaseDate)) + } + if richMedia != nil { + if homepageURL := sanitizeHomepageURL(richMedia.Homepage); homepageURL != "" { + items = append(items, fmt.Sprintf("Site: [url=%s]Clique aqui[/url]", homepageURL)) + } + } + return strings.Join(items, "\n") +} + +// sanitizeHomepageURL returns an absolute http or https homepage safe for ASC +// BBCode URL attributes, rejecting literal or escaped URL-attribute delimiters. +func sanitizeHomepageURL(rawURL string) string { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" || containsBBCodeURLAttributeUnsafeChar(trimmed) { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil || !parsed.IsAbs() || strings.TrimSpace(parsed.Host) == "" { + return "" + } + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + if scheme != "http" && scheme != "https" { + return "" + } + normalized := parsed.String() + if containsBBCodeURLAttributeUnsafeChar(normalized) || hasEscapedBBCodeURLAttributeUnsafeChar(parsed) { + return "" + } + return normalized +} + +func containsBBCodeURLAttributeUnsafeChar(value string) bool { + if strings.ContainsAny(value, "[]\"'") { + return true + } + for _, r := range value { + if r < ' ' || r == 0x7f { + return true + } + } + return false +} + +func hasEscapedBBCodeURLAttributeUnsafeChar(parsed *url.URL) bool { + if slices.ContainsFunc([]string{parsed.Path, parsed.Fragment}, containsBBCodeURLAttributeUnsafeChar) { + return true + } + encodedValues := []string{parsed.RawQuery} + if parsed.User != nil { + encodedValues = append(encodedValues, parsed.User.String()) + } + for _, encoded := range encodedValues { + if encoded == "" { + continue + } + decoded, err := url.QueryUnescape(encoded) + if err != nil || containsBBCodeURLAttributeUnsafeChar(decoded) { + return true + } + } + return false +} + +func buildProductionCompanies(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil || len(meta.ProviderMetadata.TMDB.ProductionCompanies) == 0 { + return "" + } + var parts []string + parts = append(parts, "[size=4][b]Produtoras[/b][/size]") + for _, comp := range meta.ProviderMetadata.TMDB.ProductionCompanies { + if strings.TrimSpace(comp.Name) == "" { + continue + } + logo := "" + if logoPath := strings.TrimSpace(comp.LogoPath); logoPath != "" { + logo = formatImage("https://image.tmdb.org/t/p/w45" + logoPath) + } + if logo != "" { + parts = append(parts, fmt.Sprintf("%s[size=2] - [b]%s[/b][/size]", logo, comp.Name)) + } else { + parts = append(parts, fmt.Sprintf("[size=2][b]%s[/b][/size]", comp.Name)) + } + } + return strings.Join(parts, "\n") +} + +func buildCastSection(meta api.UploadSubject, richCast []richCreditItem) string { + if len(richCast) == 0 { + names := resolveCast(meta) + if len(names) == 0 { + return "" + } + limit := min(len(names), 10) + parts := make([]string, 0, limit) + for idx := range limit { + parts = append(parts, "[size=2][b]"+names[idx]+"[/b][/size]") + } + return strings.Join(parts, "\n") + } + + limit := min(len(richCast), 10) + var parts []string + for _, person := range richCast[:limit] { + profileURL := "https://i.imgur.com/eCCCtFA.png" + if strings.TrimSpace(person.ProfilePath) != "" { + profileURL = "https://image.tmdb.org/t/p/w45" + strings.TrimSpace(person.ProfilePath) + } + tmdbURL := fmt.Sprintf("https://www.themoviedb.org/person/%d?language=pt-BR", person.ID) + imgTag := formatImage(profileURL) + + charName := strings.TrimSpace(person.Character) + characterInfo := fmt.Sprintf("(%s)", person.Name) + if charName != "" { + characterInfo = fmt.Sprintf("(%s) como %s", person.Name, charName) + } + + parts = append(parts, fmt.Sprintf("[url=%s]%s[/url]\n[size=2][b]%s[/b][/size]\n", tmdbURL, imgTag, characterInfo)) + } + return strings.Join(parts, "") +} + +func buildRatingsBBCode(meta api.UploadSubject, ratingsList []map[string]any) string { + if len(ratingsList) == 0 { + return "" + } + ratingsMap := map[string]string{ + "Internet Movie Database": "[img]https://i.postimg.cc/Pr8Gv4RQ/IMDB.png[/img]", + "Rotten Tomatoes": "[img]https://i.postimg.cc/rppL76qC/rotten.png[/img]", + "Metacritic": "[img]https://i.postimg.cc/SKkH5pNg/Metacritic45x45.png[/img]", + "TMDb": "[img]https://i.postimg.cc/T13yyzyY/tmdb.png[/img]", + } + var parts []string + for _, rating := range ratingsList { + source, _ := rating["Source"].(string) + valueRaw := rating["Value"] + if source == "" || valueRaw == nil { + continue + } + value := strings.TrimSpace(fmt.Sprint(valueRaw)) + imgTag, ok := ratingsMap[source] + if !ok { + continue + } + switch source { + case "Internet Movie Database": + imdbURL := "" + if meta.ProviderMetadata.IMDB != nil { + imdbURL = meta.ProviderMetadata.IMDB.IMDbURL + } + if imdbURL == "" && meta.Identity.IMDBID > 0 { + imdbURL = fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + parts = append(parts, fmt.Sprintf("\n[url=%s]%s[/url]\n[b]%s[/b]\n", imdbURL, imgTag, value)) + case "TMDb": + category := strings.ToLower(categoryOf(meta)) + tmdbID := meta.Identity.TMDBID + parts = append(parts, fmt.Sprintf("[url=https://www.themoviedb.org/%s/%d]%s[/url]\n[b]%s[/b]\n", category, tmdbID, imgTag, value)) + default: + parts = append(parts, fmt.Sprintf("%s\n[b]%s[/b]\n", imgTag, value)) + } + } + return strings.Join(parts, "\n") +} + +func formatDate(dateStr string) string { + dateStr = strings.TrimSpace(dateStr) + if dateStr == "" || strings.EqualFold(dateStr, "N/A") { + return "N/A" + } + if t, err := time.Parse("2006-01-02", dateStr); err == nil { + return t.Format("02/01/2006") + } + return dateStr +} + +func buildMediaInfo(meta api.UploadSubject, dbPath string) string { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + text, _ := readBDSummary(meta, dbPath) + return text + case "DVD": + return metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.DVDVOBMediaInfoText), readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath))) + default: + return readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath)) + } +} + +func readBDSummary(meta api.UploadSubject, dbPath string) (string, error) { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return readTextFile(paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists))) +} + +func sanitizeDescriptionNotes(value string) string { + replacer := strings.NewReplacer( + "[user]", "", "[/user]", "", + "[align=left]", "", "[/align]", "", + "[align=right]", "", "[/align]", "", + "[alert]", "", "[/alert]", "", + "[note]", "", "[/note]", "", + "[h1]", "[u][b]", "[/h1]", "[/b][/u]", + "[h2]", "[u][b]", "[/h2]", "[/b][/u]", + "[h3]", "[u][b]", "[/h3]", "[/b][/u]", + ) + return strings.TrimSpace(replacer.Replace(value)) +} + +func formatImage(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return "[img]" + strings.TrimSpace(value) + "[/img]" +} + +func filterEmpty(values []string) []string { + out := values[:0] + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + out = append(out, value) + } + return out +} diff --git a/internal/trackers/impl/standalone/asc/description_test.go b/internal/trackers/impl/standalone/asc/description_test.go new file mode 100644 index 00000000..b3a4819e --- /dev/null +++ b/internal/trackers/impl/standalone/asc/description_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "strings" + "testing" + + descsvc "github.com/autobrr/upbrr/internal/description" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildTechnicalSheetHomepageURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + homepage string + want string + }{ + { + name: "valid https", + homepage: "https://example.com/movie", + want: "Site: [url=https://example.com/movie]Clique aqui[/url]", + }, + { + name: "valid http path and query", + homepage: "http://example.com/path/to/movie?utm_source=tmdb&lang=pt-BR", + want: "Site: [url=http://example.com/path/to/movie?utm_source=tmdb&lang=pt-BR]Clique aqui[/url]", + }, + { + name: "valid encoded path query and fragment", + homepage: "https://example.com/path%20to/movie?name=A%2BB&ok=%25#frag%20ment", + want: "Site: [url=https://example.com/path%20to/movie?name=A%2BB&ok=%25#frag%20ment]Clique aqui[/url]", + }, + { + name: "missing scheme", + homepage: "example.com/movie", + }, + { + name: "protocol relative", + homepage: "//example.com/movie", + }, + { + name: "missing host", + homepage: "https:///movie", + }, + { + name: "unsupported scheme", + homepage: "javascript:alert(1)", + }, + { + name: "bracket boundary", + homepage: "https://example.com/movie]broken", + }, + { + name: "quote boundary", + homepage: "https://example.com/movie\"broken", + }, + { + name: "newline boundary", + homepage: "https://example.com/movie\n[url=https://evil.test]", + }, + { + name: "encoded closing bracket boundary", + homepage: "https://example.com/movie%5Dbroken", + }, + { + name: "mixed case encoded opening bracket boundary", + homepage: "https://example.com/movie%5bbroken", + }, + { + name: "encoded quote boundary", + homepage: "https://example.com/movie?title=%22broken", + }, + { + name: "encoded single quote boundary", + homepage: "https://example.com/movie?title=%27broken", + }, + { + name: "encoded line feed boundary", + homepage: "https://example.com/movie?title=good%0Abad", + }, + { + name: "encoded carriage return boundary", + homepage: "https://example.com/movie?title=good%0Dbad", + }, + { + name: "encoded close tag payload", + homepage: "https://example.com/movie%5D%5B/url%5D%5Burl=https://evil.test%5D", + }, + { + name: "malformed percent escape", + homepage: "https://example.com/movie?title=%zz", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := buildTechnicalSheet(api.UploadSubject{}, &richMediaResponse{Homepage: tc.homepage}) + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestBuildTechnicalSheetHomepageURLRejectsEncodedDelimiterThroughRender(t *testing.T) { + t.Parallel() + + homepage := "https://example.com/movie%5D%5B/url%5D%5Burl=https://evil.test%5D" + sheet := buildTechnicalSheet(api.UploadSubject{}, &richMediaResponse{Homepage: homepage}) + if sheet != "" { + t.Fatalf("expected encoded delimiter homepage to be omitted, got %q", sheet) + } + rendered := descsvc.Render(sheet) + if strings.Contains(rendered, "evil.test") || strings.Contains(rendered, "= 300 { + return dupe.Failed(dupe.FailureResponseStatus, "ASC search returned non-success status", nil) + } + + doc, err := html.Parse(resp.Body) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "ASC response parse failed", err) + } + + var entries []api.DupeEntry + var tasks []ascDetailTask + seen := make(map[string]struct{}) + walkASCNodes(doc, &entries, &tasks, seen, resolveASCTitle(meta)) + + if len(tasks) > 0 { + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 5) + taskLoop: + for _, t := range tasks { + select { + case <-ctx.Done(): + break taskLoop + case sem <- struct{}{}: + } + + wg.Add(1) + go func(t ascDetailTask) { + defer wg.Done() + defer func() { <-sem }() + + name := "N/A" + reqFile, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cliente.amigos-share.club/torrents-arquivos.php?id="+t.ID, nil) + if err != nil { + h.logger.Debugf("ASC filename request creation failed for ID %s: %v", t.ID, err) + } else { + reqFile.Header.Set("User-Agent", "upbrr") + for _, cookie := range cookies { + reqFile.AddCookie(cookie) + } + fileResp, err := h.http.Do(reqFile) + if err != nil { + h.logger.Debugf("ASC filename request failed for ID %s: %v", t.ID, err) + } else { + defer fileResp.Body.Close() + if fileResp.StatusCode < 200 || fileResp.StatusCode >= 300 { + h.logger.Debugf("ASC filename request returned non-success status %d for ID %s", fileResp.StatusCode, t.ID) + } else { + fileDoc, err := html.Parse(fileResp.Body) + if err != nil { + h.logger.Debugf("ASC filename parse failed for ID %s: %v", t.ID, err) + } else { + if parsedName := getASCFilenameFromFiles(fileDoc); parsedName != "" { + name = parsedName + } else { + h.logger.Debugf("ASC filename not found in response for ID %s", t.ID) + } + } + } + } + } + + mu.Lock() + entries = append(entries, api.DupeEntry{ + Name: name, + ID: t.ID, + Link: t.Link, + SizeText: t.Size, + }) + mu.Unlock() + }(t) + } + wg.Wait() + } + + if err := ctx.Err(); err != nil { + return dupe.Failed(dupe.FailureRequest, "ASC search canceled", err) + } + return dupe.Resolved(entries, nil) +} + +func buildASCSearchURL(meta api.DuplicateSubject) string { + base := "https://cliente.amigos-share.club" + if meta.Anime { + return base + "/torrents-search.php?search=" + url.QueryEscape(resolveASCTitle(meta)) + } + if strings.EqualFold(resolveASCCategory(meta), "TV") { + return base + "/busca-series.php?search=" + url.QueryEscape(resolveASCSeasonEpisode(meta)) + "&imdb=" + url.QueryEscape(resolveASCIMDb(meta)) + } + return base + "/busca-filmes.php?search=&imdb=" + url.QueryEscape(resolveASCIMDb(meta)) +} + +type ascDetailTask struct { + ID string + Link string + Size string +} + +func walkASCNodes(n *html.Node, entries *[]api.DupeEntry, tasks *[]ascDetailTask, seen map[string]struct{}, baseTitle string) { + if n.Type == html.ElementNode && strings.EqualFold(n.Data, "li") { + if ascHasClass(n, "list-group-item") && ascHasClass(n, "dark-gray") { + processASCListItem(n, entries, tasks, seen, baseTitle) + return + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walkASCNodes(c, entries, tasks, seen, baseTitle) + } +} + +func processASCListItem(n *html.Node, entries *[]api.DupeEntry, tasks *[]ascDetailTask, seen map[string]struct{}, baseTitle string) { + var detailsHref string + var sizeText string + var badges []string + + var walk func(*html.Node) + walk = func(child *html.Node) { + if child.Type == html.ElementNode && strings.EqualFold(child.Data, "a") { + href := ascGetAttr(child, "href") + if strings.Contains(href, "torrents-details.php?id=") { + detailsHref = href + } + } + if child.Type == html.ElementNode && strings.EqualFold(child.Data, "span") { + if ascHasClass(child, "badge-info") { + txt := strings.ToUpper(ascGetText(child)) + if strings.Contains(txt, "GB") || strings.Contains(txt, "MB") { + sizeText = strings.TrimSpace(ascGetText(child)) + } + } else if ascHasClass(child, "badge") { + badges = append(badges, strings.TrimSpace(ascGetText(child))) + } + } + for c := child.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + + if detailsHref == "" { + return + } + + id := parseASCTorrentID(detailsHref) + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + + link := absolutizeASCLink(detailsHref) + + discTypes := []string{"BD25", "BD50", "BD66", "BD100", "DVD5", "DVD9"} + isDisc := false + for _, b := range badges { + bUp := strings.ToUpper(b) + if ascContainsAny(bUp, discTypes) { + isDisc = true + break + } + } + + if isDisc { + year := "N/A" + resolution := "N/A" + diskType := "N/A" + videoCodec := "N/A" + audioCodec := "N/A" + + codecTerms := []string{"MPEG-4", "AV1", "AVC", "H264", "H265", "HEVC", "MPEG-1", "MPEG-2", "VC-1", "VP6", "VP9"} + audioTerms := []string{"DTS", "AC3", "DDP", "E-AC-3", "TRUEHD", "ATMOS", "LPCM", "AAC", "FLAC"} + resTypes := []string{"4K", "2160P", "1080P", "720P", "480P"} + + for _, b := range badges { + bUp := strings.ToUpper(b) + switch { + case len(bUp) == 4 && ascIsDigit(bUp): + year = bUp + case ascContainsAnyStrict(bUp, resTypes): + if bUp == "4K" { + resolution = "2160p" + } else { + resolution = bUp + } + case ascContainsAny(bUp, codecTerms): + videoCodec = strings.TrimSpace(b) + case ascContainsAny(bUp, audioTerms): + audioCodec = strings.TrimSpace(b) + case ascContainsAny(bUp, discTypes): + diskType = strings.TrimSpace(b) + } + } + + name := fmt.Sprintf("%s %s %s %s %s %s", baseTitle, year, resolution, diskType, strings.ToUpper(videoCodec), strings.ToUpper(audioCodec)) + *entries = append(*entries, api.DupeEntry{ + Name: strings.TrimSpace(name), + ID: id, + Link: link, + SizeText: sizeText, + }) + } else { + *tasks = append(*tasks, ascDetailTask{ + ID: id, + Link: link, + Size: sizeText, + }) + } +} + +func ascContainsAnyStrict(s string, terms []string) bool { + return slices.Contains(terms, s) +} + +func ascContainsAny(s string, terms []string) bool { + for _, t := range terms { + if strings.Contains(s, t) { + return true + } + } + return false +} + +func ascIsDigit(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func ascHasClass(n *html.Node, class string) bool { + for _, attr := range n.Attr { + if strings.EqualFold(attr.Key, "class") { + for c := range strings.FieldsSeq(attr.Val) { + if strings.EqualFold(c, class) { + return true + } + } + return false + } + } + return false +} + +func ascGetAttr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if strings.EqualFold(attr.Key, key) { + return strings.TrimSpace(attr.Val) + } + } + return "" +} + +func ascGetText(n *html.Node) string { + var b strings.Builder + var walk func(*html.Node) + walk = func(child *html.Node) { + if child.Type == html.TextNode { + b.WriteString(child.Data) + } + for c := child.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return b.String() +} + +func getASCFilenameFromFiles(n *html.Node) string { + var walk func(*html.Node) string + walk = func(child *html.Node) string { + if child.Type == html.ElementNode && strings.EqualFold(child.Data, "li") && ascHasClass(child, "list-group-item") { + for c := child.FirstChild; c != nil; c = c.NextSibling { + switch c.Type { + case html.TextNode: + txt := strings.TrimSpace(c.Data) + if txt != "" { + return txt + } + case html.ElementNode: + txt := strings.TrimSpace(ascGetText(c)) + if txt != "" { + return txt + } + case html.ErrorNode, html.DocumentNode, html.CommentNode, html.DoctypeNode, html.RawNode: + // ignore + } + } + } + for c := child.FirstChild; c != nil; c = c.NextSibling { + if txt := walk(c); txt != "" { + return txt + } + } + return "" + } + return walk(n) +} + +func parseASCTorrentID(href string) string { + parsed, err := url.Parse(href) + if err == nil { + if id := strings.TrimSpace(parsed.Query().Get("id")); id != "" { + return id + } + } + parts := strings.Split(href, "id=") + if len(parts) < 2 { + return "" + } + return strings.TrimSpace(strings.Split(parts[len(parts)-1], "&")[0]) +} + +func absolutizeASCLink(href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + return "https://cliente.amigos-share.club/" + strings.TrimPrefix(href, "/") +} + +func resolveASCIMDb(meta api.DuplicateSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveASCCategory(meta api.DuplicateSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func resolveASCTitle(meta api.DuplicateSubject) string { + if strings.TrimSpace(meta.Release.Title) != "" { + return strings.TrimSpace(meta.Release.Title) + } + if strings.TrimSpace(meta.ReleaseName) != "" { + return strings.TrimSpace(meta.ReleaseName) + } + return strings.TrimSpace(meta.SourcePath) +} + +func resolveASCSeasonEpisode(meta api.DuplicateSubject) string { + if meta.EpisodeInt > 0 { + return "S" + padASC2(meta.SeasonInt) + "E" + padASC2(meta.EpisodeInt) + } + if meta.SeasonInt > 0 { + return "S" + padASC2(meta.SeasonInt) + } + return "" +} + +func padASC2(value int) string { + if value < 10 { + return "0" + strconv.Itoa(value) + } + return strconv.Itoa(value) +} diff --git a/internal/trackers/impl/standalone/asc/metadata.go b/internal/trackers/impl/standalone/asc/metadata.go new file mode 100644 index 00000000..386e732b --- /dev/null +++ b/internal/trackers/impl/standalone/asc/metadata.go @@ -0,0 +1,672 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/pkg/api" +) + +func resolveContainer(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + return "5" + case "DVD": + return "15" + } + ext := strings.ToLower(strings.TrimSpace(meta.Container)) + if ext == "" { + ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(metautil.FirstNonEmptyTrimmed(meta.VideoPath, meta.SourcePath)), ".")) + } + switch ext { + case "mkv": + return "6" + case "mp4": + return "8" + default: + return "" + } +} + +func resolveQuality(meta api.UploadSubject) string { + if !strings.EqualFold(meta.Type, "DISC") { + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "ENCODE": + return "9" + case "REMUX": + return "39" + case "WEBDL": + return "23" + case "WEBRIP": + return "38" + case "BDRIP": + return "8" + case "DVDRIP": + return "3" + default: + return "0" + } + } + if strings.EqualFold(meta.DiscType, "DVD") { + if meta.SourceSize > 7_500_000_000 { + return "46" + } + return "45" + } + if strings.EqualFold(meta.DiscType, "HDDVD") { + return "15" + } + switch size := meta.SourceSize; { + case size > 66<<30: + return "43" + case size > 50<<30: + return "42" + case size > 25<<30: + return "41" + default: + return "40" + } +} + +func resolveResolution(meta api.UploadSubject) map[string]string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + heightStr := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(meta.Release.Resolution), "p"), "i") + heightNum, err := strconv.Atoi(heightStr) + if err == nil && heightNum > 0 { + widthNum := int(math.Round((16.0 / 9.0) * float64(heightNum))) + return map[string]string{ + "width": strconv.Itoa(widthNum), + "height": strconv.Itoa(heightNum), + } + } + } + + if meta.MediaInfoJSONPath != "" { + if payload, err := os.ReadFile(meta.MediaInfoJSONPath); err == nil { + type mediaInfoDoc struct { + Media struct { + Track []map[string]any `json:"track"` + } `json:"media"` + } + var doc mediaInfoDoc + if err := json.Unmarshal(payload, &doc); err == nil { + for _, track := range doc.Media.Track { + trackType, _ := track["@type"].(string) + if strings.ToLower(trackType) == "video" { + widthVal := track["Width"] + heightVal := track["Height"] + + widthStr := parseDimensionStr(widthVal) + heightStr := parseDimensionStr(heightVal) + + if widthStr != "" && heightStr != "" { + return map[string]string{ + "width": widthStr, + "height": heightStr, + } + } + } + } + } + } + } + + height := parseResolutionHeight(meta.Release.Resolution) + if height == 0 { + height = parseResolutionHeight(meta.ReleaseName) + } + width := 0 + if height > 0 { + width = int(float64(height) * (16.0 / 9.0)) + } + return map[string]string{ + "width": intString(width), + "height": intString(height), + } +} + +func resolveVideoCodec(meta api.UploadSubject) string { + codec := strings.ToUpper(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.VideoEncode, meta.VideoCodec))) + codecClean := codec + if strings.Contains(codec, "264") { + codecClean = "H264" + } else if strings.Contains(codec, "265") { + codecClean = "HEVC" + } + switch { + case strings.Contains(strings.ToUpper(meta.HDR), "HDR") && (codecClean == "HEVC" || codecClean == "H265"): + return "28" + case strings.Contains(strings.ToUpper(meta.HDR), "HDR") && (codecClean == "AVC" || codecClean == "H264"): + return "32" + case strings.Contains(codec, "AV1"): + return "29" + case strings.Contains(codec, "HEVC"): + return "27" + case strings.Contains(codec, "H265"): + return "18" + case strings.Contains(codec, "AVC"): + return "30" + case strings.Contains(codec, "H264"): + return "17" + case strings.Contains(codec, "VC-1"): + return "21" + case strings.Contains(codec, "MPEG-2"): + return "11" + default: + return "16" + } +} + +func resolveAudioCodec(meta api.UploadSubject) string { + audio := strings.ToUpper(strings.TrimSpace(meta.Audio)) + switch { + case strings.Contains(audio, "ATMOS"): + return "43" + case strings.Contains(audio, "DTS:X"): + return "25" + case strings.Contains(audio, "DTS-HD MA"): + return "24" + case strings.Contains(audio, "DTS-HD"): + return "23" + case strings.Contains(audio, "TRUEHD"): + return "29" + case strings.Contains(audio, "DD+"), strings.Contains(audio, "E-AC-3"): + return "26" + case strings.Contains(audio, "DD"), strings.Contains(audio, "AC3"): + return "11" + case strings.Contains(audio, "DTS"): + return "12" + case strings.Contains(audio, "FLAC"): + return "13" + case strings.Contains(audio, "LPCM"): + return "21" + case strings.Contains(audio, "PCM"): + return "28" + case strings.Contains(audio, "AAC"): + return "10" + case strings.Contains(audio, "OPUS"): + return "27" + case strings.Contains(audio, "MPEG"): + return "17" + default: + return "20" + } +} + +func resolveAudio(meta api.UploadSubject) string { + original := strings.ToLower(strings.TrimSpace(resolveOriginalLanguage(meta))) + audioLangs := lowerStrings(meta.AudioLanguages) + hasPTAudio := containsAny(audioLangs, []string{"portuguese", "português", "pt"}) + hasPTSubs := resolveSubtitle(meta) == "Embutida" + isOriginalPT := containsAny([]string{original}, []string{"portuguese", "português", "pt"}) + switch { + case hasPTAudio && isOriginalPT: + return "4" + case hasPTAudio && countNonPortuguese(audioLangs) > 0: + return "2" + case hasPTAudio: + return "3" + case hasPTSubs: + return "1" + default: + return "7" + } +} + +func resolveSubtitle(meta api.UploadSubject) string { + if containsAny(lowerStrings(meta.SubtitleLanguages), []string{"portuguese", "português", "pt", "brazilian portuguese"}) { + return "Embutida" + } + return "S_legenda" +} + +func resolveLanguage(meta api.UploadSubject) string { + return mapLanguage(resolveOriginalLanguage(meta), map[string]string{ + "bg": "15", + "da": "12", + "de": "3", + "en": "1", + "es": "6", + "fi": "14", + "fr": "2", + "hi": "23", + "it": "4", + "ja": "5", + "ko": "20", + "nl": "17", + "no": "16", + "pl": "19", + "pt": "8", + "ru": "7", + "sv": "13", + "th": "21", + "tr": "25", + "zh": "10", + }, "11") +} + +func resolveAnimeLanguage(meta api.UploadSubject) string { + return mapLanguage(resolveOriginalLanguage(meta), map[string]string{ + "de": "3", + "en": "4", + "es": "1", + "ja": "8", + "ko": "11", + "pt": "5", + "ru": "2", + "zh": "9", + }, "6") +} + +func resolveAnimeAudioLanguage(meta api.UploadSubject) string { + if audio := resolveAudio(meta); audio == "2" || audio == "3" || audio == "4" { + return "8" + } + return resolveLanguage(meta) +} + +func resolveAnimeType(meta api.UploadSubject) string { + if categoryOf(meta) == "TV" { + return "118" + } + return "116" +} + +func resolveUploadTitle(meta api.UploadSubject) string { + base := resolveDisplayTitle(meta) + if categoryOf(meta) == "TV" { + return strings.TrimSpace( + base + " - " + metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.SeasonStr)+strings.TrimSpace(meta.EpisodeStr), seasonEpisodeText(meta)), + ) + } + return base +} + +func resolveDisplayTitle(meta api.UploadSubject) string { + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + if tmdb := meta.ProviderMetadata.TMDB; tmdb != nil { + main := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(ptBR.Title, tmdb.Title, meta.Release.Title)) + alt := strings.TrimSpace(tmdb.OriginalTitle) + if categoryOf(meta) == "TV" { + alt = strings.TrimSpace(metautil.FirstNonEmptyTrimmed(tmdb.Title, meta.Release.Title)) + } + if main != "" && alt != "" && !strings.EqualFold(main, alt) { + return main + " (" + alt + ")" + } + if main != "" { + return main + } + } + return strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName, pathutil.Base(meta.SourcePath))) +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + if meta.ProviderMetadata.TMDB.Localized != nil { + if localized, ok := meta.ProviderMetadata.TMDB.Localized["pt-BR"]; ok && strings.TrimSpace(localized.Poster) != "" { + return strings.TrimSpace(localized.Poster) + } + } + if strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + } + switch { + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Poster) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.Poster) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster) + default: + return "" + } +} + +func resolveOverview(meta api.UploadSubject, answers map[string]string) string { + if strings.TrimSpace(answers["overview"]) != "" { + return strings.TrimSpace(answers["overview"]) + } + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + if shouldUseScopedTVOverview(meta) && ptBR.EpisodeOverview != "" { + return strings.TrimSpace(ptBR.EpisodeOverview) + } + if ptBR.Overview != "" { + return strings.TrimSpace(ptBR.Overview) + } + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Overview) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.Overview) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Summary) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Summary) + default: + return strings.TrimSpace(meta.EpisodeOverview) + } +} + +// shouldUseScopedTVOverview reports whether ASC should prefer season or +// episode localized overview over title-level synopsis text. +func shouldUseScopedTVOverview(meta api.UploadSubject) bool { + if meta.SeasonInt <= 0 { + return false + } + if categoryOf(meta) != "TV" { + return false + } + if meta.TVPack { + return true + } + return meta.EpisodeInt > 0 +} + +func resolveGenres(meta api.UploadSubject, answers map[string]string) string { + if strings.TrimSpace(answers["genre"]) != "" { + return strings.TrimSpace(answers["genre"]) + } + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + + // 1. Use localized if available + if ptBR.Genres != "" { + genres := strings.Split(strings.TrimSpace(ptBR.Genres), ",") + out := make([]string, 0, len(genres)) + for _, genre := range genres { + g := strings.TrimSpace(genre) + capitalized := metautil.CapitalizeGenre(g) + if capitalized != "" { + out = append(out, capitalized) + } + } + return strings.Join(out, ", ") + } + + // 2. Use metautil.TranslateGenreToPortugueseStrict to translate + var genreText string + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.TVDB.Genres) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.TVmaze.Genres) + default: + genreText = strings.TrimSpace(meta.Release.Genre) + } + + if genreText == "" { + return "" + } + + genres := strings.Split(genreText, ",") + out := make([]string, 0, len(genres)) + for _, genre := range genres { + g := strings.TrimSpace(genre) + if g == "" { + continue + } + translated := metautil.TranslateGenreToPortugueseStrict(g) + if translated == "" { + translated = g + } + capitalized := metautil.CapitalizeGenre(translated) + if capitalized != "" { + out = append(out, capitalized) + } + } + return strings.Join(out, ", ") +} + +func resolveTrailer(meta api.UploadSubject) string { + value := "" + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + if ptBR.TrailerURL != "" { + value = ptBR.TrailerURL + } + if value == "" && meta.ProviderMetadata.TMDB != nil { + value = strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + if value == "" { + return "" + } + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value + } + return "https://www.youtube.com/watch?v=" + value +} + +func resolveIMDbIDText(meta api.UploadSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveOriginalLanguage(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.OriginalLanguage) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.OriginalLanguage) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Language) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Language) + default: + return "" + } +} + +func resolveRuntime(meta api.UploadSubject) string { + minutes := 0 + switch { + case meta.ProviderMetadata.TMDB != nil: + minutes = meta.ProviderMetadata.TMDB.Runtime + case meta.ProviderMetadata.IMDB != nil: + minutes = meta.ProviderMetadata.IMDB.RuntimeMinutes + case meta.ProviderMetadata.TVmaze != nil: + minutes = meta.ProviderMetadata.TVmaze.Runtime + } + if minutes <= 0 { + return "" + } + hours := minutes / 60 + remain := minutes % 60 + if hours == 0 { + return fmt.Sprintf("%02d minutos", remain) + } + return fmt.Sprintf("%d hora%s e %02d minutos", hours, pluralSuffix(hours), remain) +} + +func resolveCountries(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.ProductionCountries) > 0 { + names := make([]string, 0, len(meta.ProviderMetadata.TMDB.ProductionCountries)) + for _, country := range meta.ProviderMetadata.TMDB.ProductionCountries { + if strings.TrimSpace(country.Name) != "" { + names = append(names, strings.TrimSpace(country.Name)) + } + } + return strings.Join(names, ", ") + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.CountryList) + } + return "" +} + +func resolveCast(meta api.UploadSubject) []string { + switch { + case meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Cast) > 0: + return append([]string{}, meta.ProviderMetadata.TMDB.Cast...) + case meta.ProviderMetadata.IMDB != nil && len(meta.ProviderMetadata.IMDB.Stars) > 0: + names := make([]string, 0, len(meta.ProviderMetadata.IMDB.Stars)) + for _, person := range meta.ProviderMetadata.IMDB.Stars { + if strings.TrimSpace(person.Name) != "" { + names = append(names, strings.TrimSpace(person.Name)) + } + } + return names + default: + return nil + } +} + +func resolveReleaseDate(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.ReleaseDate) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.ReleaseDate) + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.FirstAirDate) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.FirstAirDate) + case meta.ProviderMetadata.TVDB != nil && strings.TrimSpace(meta.ProviderMetadata.TVDB.FirstAired) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVDB.FirstAired) + case meta.ProviderMetadata.TVmaze != nil && strings.TrimSpace(meta.ProviderMetadata.TVmaze.Premiered) != "": + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Premiered) + default: + return "" + } +} + +func resolveYear(meta api.UploadSubject) int { + switch { + case meta.Release.Year > 0: + return meta.Release.Year + case meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0: + return meta.ProviderMetadata.TMDB.Year + case meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0: + return meta.ProviderMetadata.IMDB.Year + case meta.ProviderMetadata.TVDB != nil && meta.ProviderMetadata.TVDB.Year > 0: + return meta.ProviderMetadata.TVDB.Year + default: + return 0 + } +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func seasonEpisodeText(meta api.UploadSubject) string { + if meta.EpisodeInt > 0 { + return fmt.Sprintf("S%02dE%02d", meta.SeasonInt, meta.EpisodeInt) + } + if meta.SeasonInt > 0 { + return fmt.Sprintf("S%02d", meta.SeasonInt) + } + return "" +} + +func boolFlag(ok bool) string { + if ok { + return "1" + } + return "2" +} + +func parseResolutionHeight(value string) int { + re := regexp.MustCompile(`(?i)(\d{3,4})(?:p|i)`) + matches := re.FindStringSubmatch(value) + if len(matches) != 2 { + return 0 + } + height, _ := strconv.Atoi(matches[1]) + return height +} + +func intString(value int) string { + if value <= 0 { + return "" + } + return strconv.Itoa(value) +} + +func mapLanguage(value string, mappings map[string]string, fallback string) string { + key := strings.ToLower(strings.TrimSpace(value)) + if mapped, ok := mappings[key]; ok { + return mapped + } + return fallback +} + +func lowerStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + out = append(out, strings.ToLower(strings.TrimSpace(value))) + } + return out +} + +func countNonPortuguese(values []string) int { + count := 0 + for _, value := range values { + if !containsAny([]string{value}, []string{"portuguese", "português", "pt"}) { + count++ + } + } + return count +} + +func containsAny(values []string, targets []string) bool { + for _, value := range values { + for _, target := range targets { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { + return true + } + } + } + return false +} + +func containsFold(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { + return true + } + } + return false +} + +func pluralSuffix(value int) string { + if value == 1 { + return "" + } + return "s" +} + +func readTextFile(path string) (string, error) { + payload, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return "", fmt.Errorf("trackers: ASC read text file: %w", err) + } + return strings.ReplaceAll(string(payload), "\r", ""), nil +} + +func readTextFileNoErr(path string) string { + value, _ := readTextFile(path) + return value +} + +func parseDimensionStr(val any) string { + return metautil.ParseDimensionStr(val) +} diff --git a/internal/trackers/impl/standalone/asc/metadata_test.go b/internal/trackers/impl/standalone/asc/metadata_test.go new file mode 100644 index 00000000..484f3ed5 --- /dev/null +++ b/internal/trackers/impl/standalone/asc/metadata_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveGenresPreservesUnknownGenres(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Genre: "Sci-Fi,MyCustomGenre", + }, + } + answers := map[string]string{} + + got := resolveGenres(meta, answers) + expected := "Ficção científica, MyCustomGenre" + if got != expected { + t.Fatalf("expected genres %q, got %q", expected, got) + } +} + +func TestResolveOverviewUsesScopedTVOverviewOnlyForEpisodeOrSeasonPack(t *testing.T) { + t.Parallel() + + answers := map[string]string{} + ptBR := api.TMDBLocalizedData{ + Overview: "Series Overview", + EpisodeOverview: "Episode Overview", + } + + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "episode upload uses episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + EpisodeInt: 2, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{"pt-BR": ptBR}}, + }, + }, + want: "Episode Overview", + }, + { + name: "season pack uses season overview from episode field", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + TVPack: true, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{"pt-BR": ptBR}}, + }, + }, + want: "Episode Overview", + }, + { + name: "series upload uses title overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{"pt-BR": ptBR}}, + }, + }, + want: "Series Overview", + }, + { + name: "movie ignores episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + SeasonInt: 1, + EpisodeInt: 2, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{"pt-BR": ptBR}}, + }, + }, + want: "Series Overview", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := resolveOverview(tc.meta, answers); got != tc.want { + t.Fatalf("expected overview %q, got %q", tc.want, got) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/asc/profile.go b/internal/trackers/impl/standalone/asc/profile.go new file mode 100644 index 00000000..3cfa177f --- /dev/null +++ b/internal/trackers/impl/standalone/asc/profile.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns ASC identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "ASC", + BaseURL: baseURL, + DescriptionGroup: "asc", + UploadContentMode: trackers.UploadContentModeDescription, + LocalizedMetadataLocale: "pt-BR", + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + AudioPolicy: &trackers.AudioPolicy{AllowBloat: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"amigos-share.club"}}, + AuthCapability: standalone.CookieAuthCapability("ASC"), + } +} + +// New returns a fresh ASC definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/asc/session.go b/internal/trackers/impl/standalone/asc/session.go similarity index 79% rename from internal/trackers/impl/asc/session.go rename to internal/trackers/impl/standalone/asc/session.go index 6af34179..d3e0c8e6 100644 --- a/internal/trackers/impl/asc/session.go +++ b/internal/trackers/impl/standalone/asc/session.go @@ -11,8 +11,9 @@ import ( cookiepkg "github.com/autobrr/upbrr/internal/cookies" ) -// LoadCookies loads ASC cookies from shared storage. -// Callers must pass a valid non-nil context. +// LoadCookies loads ASC cookies from shared storage for the ASC web domain. +// The legacy source-label return value is always empty. Callers must pass a +// valid non-nil context. func LoadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, string, error) { loaded, err := cookiepkg.LoadTrackerHTTPCookies(ctx, dbPath, sourceFlag, "cliente.amigos-share.club") if err != nil { diff --git a/internal/trackers/impl/standalone/asc/upload.go b/internal/trackers/impl/standalone/asc/upload.go new file mode 100644 index 00000000..bfbaaa15 --- /dev/null +++ b/internal/trackers/impl/standalone/asc/upload.go @@ -0,0 +1,429 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package asc + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +var ascIDPattern = regexp.MustCompile(`torrents-details\.php\?id=(\d+)`) + +type uploadState struct { + uploadURL string + torrentPath string + description string + fields map[string]string + blockedReason string + questionnaire *api.TrackerQuestionnaire + releaseName string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: ASC %s", state.blockedReason) + } + + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "ASC") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ASC request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", userAgent) + for _, cookie := range cookies { + httpReq.AddCookie(cookie) + } + + client := httpclient.New(httpclient.DefaultTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ASC upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + bodyBytes, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: ASC read upload response: %w", err) + } + torrentID := parseUploadID(finalURL, string(bodyBytes)) + if resp.StatusCode == http.StatusOK && torrentID != "" { + torrentURL := baseURL + "/torrents-details.php?id=" + url.QueryEscape(torrentID) + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + maybeAutoApprove(ctx, client, cookies, req.TrackerConfig, torrentID, req.Logger) + maybeSetInternal(ctx, client, cookies, req.TrackerConfig, req.Meta, torrentID, req.Logger) + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "ASC", + TorrentID: torrentID, + TorrentURL: torrentURL, + DownloadURL: torrentURL, + TorrentPath: artifactPath, + }}, + }, nil + } + + failurePath := "" + if pathValue, pathErr := resolveFailurePath(req.Meta, req.Runtime.DBPath); pathErr == nil { + failurePath = pathValue + redactedBody := []byte(redaction.RedactValue(string(responsePreview), nil)) + _ = os.WriteFile(failurePath, redactedBody, 0o600) + } + if failurePath != "" { + return api.UploadSummary{}, fmt.Errorf( + "%w failure=%s", + commonhttp.UploadHTTPErrorWithURL("ASC", resp.StatusCode, finalURL, responsePreview), + failurePath, + ) + } + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("ASC", resp.StatusCode, finalURL, responsePreview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "ASC", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "asc", + Description: state.description, + Endpoint: state.uploadURL, + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + + description := buildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets, req.TrackerConfig.CustomLayout) + + fields, releaseName := buildPayload(req.Meta, req.TrackerConfig, assets, description) + state := uploadState{ + uploadURL: resolveUploadURL(req.Meta), + torrentPath: torrentPath, + description: description, + fields: fields, + releaseName: releaseName, + questionnaire: buildQuestionnaire(req.Meta), + } + if reason := validatePayload(ctx, req.Meta, fields, req.Runtime.DBPath); reason != "" { + state.blockedReason = reason + } + if dryRun { + return state, nil, nil + } + + cookies, _, err := LoadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: ASC load cookies: %w", err) + } + return state, cookies, nil +} + +func buildPayload( + meta api.UploadSubject, + trackerCfg config.TrackerConfig, + assets trackers.DescriptionAssets, + description string, +) (map[string]string, string) { + answers := standalone.QuestionnaireAnswers(meta, "ASC") + releaseName := resolveUploadTitle(meta) + fields := map[string]string{ + "ano": strconv.Itoa(resolveYear(meta)), + "audio": resolveAudio(meta), + "capa": resolvePoster(meta), + "codecaudio": resolveAudioCodec(meta), + "codecvideo": resolveVideoCodec(meta), + "descr": description, + "extencao": resolveContainer(meta), + "genre": resolveGenres(meta, answers), + "imdb": resolveIMDbIDText(meta), + "layout": metautil.FirstNonEmptyTrimmed(strings.TrimSpace(trackerCfg.CustomLayout), "2"), + "legenda": resolveSubtitle(meta), + "name": releaseName, + "qualidade": resolveQuality(meta), + "takeupload": "yes", + "tresd": boolFlag(meta.Is3D != ""), + "tube": resolveTrailer(meta), + } + resolution := resolveResolution(meta) + fields["largura"] = resolution["width"] + fields["altura"] = resolution["height"] + fields["lang"] = resolveLanguage(meta) + count := 1 + for _, image := range assets.Screenshots { + if count > 4 { + break + } + raw := strings.TrimSpace(image.RawURL) + if raw == "" { + continue + } + lower := strings.ToLower(raw) + if strings.Contains(lower, "amigos-share.club") || strings.Contains(lower, "tmdb.org") || strings.Contains(lower, "imdb.com") || + strings.Contains(lower, "themoviedb.org") { + continue + } + fields[fmt.Sprintf("screens%d", count)] = raw + count++ + } + if meta.Anime { + fields["type"] = resolveAnimeType(meta) + fields["idioma"] = resolveAnimeLanguage(meta) + fields["lang"] = resolveAnimeAudioLanguage(meta) + } + return fields, releaseName +} + +func buildQuestionnaire(meta api.UploadSubject) *api.TrackerQuestionnaire { + answers := standalone.QuestionnaireAnswers(meta, "ASC") + fields := make([]api.TrackerQuestionnaireField, 0, 2) + if strings.TrimSpace(resolveOverview(meta, answers)) == "" { + fields = append(fields, api.TrackerQuestionnaireField{ + Key: "overview", + Label: "Sinopse", + Kind: "textarea", + Value: strings.TrimSpace(answers["overview"]), + Required: true, + }) + } + if strings.TrimSpace(resolveGenres(meta, answers)) == "" { + fields = append(fields, api.TrackerQuestionnaireField{ + Key: "genre", + Label: "Gêneros", + Kind: "text", + Value: strings.TrimSpace(answers["genre"]), + Placeholder: "Drama, Action", + Required: true, + }) + } + if len(fields) == 0 { + return nil + } + return &api.TrackerQuestionnaire{Tracker: "ASC", Fields: fields} +} + +func validatePayload(ctx context.Context, meta api.UploadSubject, fields map[string]string, dbPath string) string { + if reason := authProblem(ctx, dbPath); reason != "" { + return reason + } + if !meta.Anime && strings.TrimSpace(fields["imdb"]) == "" { + return "missing IMDb ID" + } + if strings.TrimSpace(fields["capa"]) == "" { + return "missing poster URL" + } + if strings.TrimSpace(fields["genre"]) == "" { + return "missing genre" + } + if strings.TrimSpace(resolveOverview(meta, standalone.QuestionnaireAnswers(meta, "ASC"))) == "" { + return "missing overview" + } + return "" +} + +func resolveUploadURL(meta api.UploadSubject) string { + if meta.Anime { + return baseURL + "/enviar-anime.php" + } + if categoryOf(meta) == "TV" { + return baseURL + "/enviar-series.php" + } + return baseURL + "/enviar-filme.php" +} + +func parseUploadID(finalURL string, body string) string { + if matches := ascIDPattern.FindStringSubmatch(finalURL); len(matches) == 2 { + return strings.TrimSpace(matches[1]) + } + if matches := ascIDPattern.FindStringSubmatch(body); len(matches) == 2 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + file, err := os.Open(strings.TrimSpace(torrentPath)) + if err != nil { + return nil, "", fmt.Errorf("trackers: ASC open torrent file: %w", err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return nil, "", fmt.Errorf("trackers: ASC write multipart field %q: %w", key, err) + } + } + part, err := writer.CreateFormFile("torrent", filepath.Base(torrentPath)) + if err != nil { + return nil, "", fmt.Errorf("trackers: ASC create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, "", fmt.Errorf("trackers: ASC copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: ASC close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func maybeAutoApprove(ctx context.Context, client *http.Client, cookies []*http.Cookie, cfg config.TrackerConfig, torrentID string, logger api.Logger) { + if client == nil { + logger.Warnf("trackers: ASC auto approval skipped: client is nil") + return + } + if !cfg.UploaderStatus { + logger.Debugf("trackers: ASC auto approval skipped: uploader status disabled") + return + } + if strings.TrimSpace(torrentID) == "" { + logger.Warnf("trackers: ASC auto approval skipped: empty torrentID") + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/uploader_app.php?id="+url.QueryEscape(torrentID), nil) + if err != nil { + logger.Warnf("trackers: ASC auto approval failed: %v", err) + return + } + req.Header.Set("User-Agent", userAgent) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + resp, err := client.Do(req) + if err != nil && logger != nil { + logger.Warnf("trackers: ASC auto approval failed: %v", err) + } + if resp != nil { + resp.Body.Close() + } +} + +func maybeSetInternal( + ctx context.Context, + client *http.Client, + cookies []*http.Cookie, + cfg config.TrackerConfig, + meta api.UploadSubject, + torrentID string, + logger api.Logger, +) { + if client == nil || !cfg.Internal || strings.TrimSpace(torrentID) == "" { + logger.Debugf("trackers: ASC internal flag skipped: %v", "client is nil or internal is false or torrentID is empty") + return + } + group := strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-") + if group == "" || !containsFold(cfg.InternalGroups, group) { + logger.Debugf("trackers: ASC internal flag skipped: %v", "group is empty or not in internal groups") + return + } + values := url.Values{"id": {torrentID}, "internal": {"yes"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/torrents-edit.php?action=doedit", strings.NewReader(values.Encode())) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + resp, err := client.Do(req) + if err != nil && logger != nil { + logger.Warnf("trackers: ASC internal flag failed: %v", err) + } + if resp != nil { + resp.Body.Close() + } +} + +func resolveFailurePath(meta api.UploadSubject, dbPath string) (string, error) { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return filepath.Join(tmpDir, "[ASC]upload_failure.html"), nil +} diff --git a/internal/trackers/impl/standalone/bhd/bbcode.go b/internal/trackers/impl/standalone/bhd/bbcode.go new file mode 100644 index 00000000..17bca845 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/bbcode.go @@ -0,0 +1,166 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" + imagehost "github.com/autobrr/upbrr/internal/imagehosting/host" +) + +// BBCodeOptions controls tracker-specific description cleanup and artifact +// extraction. BaseDir and UUID are retained inputs but are not currently read. +type BBCodeOptions struct { + // Framestor preserves the original description as a bhd.nfo artifact. + Framestor bool + // Flux wraps non-empty cleaned text in a code block. + Flux bool + BaseDir string + UUID string + // OnNFO receives the original description before cleanup when Framestor is set. + // Callback failures are recorded as report notes rather than returned. + OnNFO func(text string) error +} + +var ( + bhdSizePattern = regexp.MustCompile(`(?i)\[size=.*?\]`) + bhdURLImgPattern = regexp.MustCompile(`(?is)\[url=(https?://[^\]]+)\]\s*\[img(?:=[^\]]*)?\](https?://[^\[]+?)\[/img\]\s*\[/url\]`) + bhdImgTagPattern = regexp.MustCompile(`(?is)\[img(?:=[^\]]*)?\](https?://[^\[]+?)\[/img\]`) + bhdLooseImg = regexp.MustCompile(`(?i)(https?://[^\s\[\]]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s\[\]]*)?)`) + bhdEmptyCenter = regexp.MustCompile(`(?i)\[center\]\s*\[/center\]`) + bhdEmptyAlign = regexp.MustCompile(`(?i)\[align=[^\]]+\]\s*\[/align\]`) + bhdTonemapNotice = regexp.MustCompile( + `(?is)\[(?:center|align=center)\]\s*\[code\]\s*Screenshots\s+have\s+been\s+tonemapped\s+for\s+reference\s*\[/code\]\s*\[/(?:center|align)\]`, + ) + bhdBotSignature = regexp.MustCompile( + `(?is)(?:\[(?:center|right|align=right)\]\s*(?:\[img=\d+\]https://blutopia\.xyz/favicon\.ico\[/img\]\s*)?\[b\]?Uploaded\s+Using\s+\[url=https://github\.com/HDInnovations/UNIT3D\]UNIT3D\[/url\]\s+Auto\s+Uploader\[/b\]?(?:\s*\[img=\d+\]https://blutopia\.xyz/favicon\.ico\[/img\])?\s*\[/(?:center|right|align)\])|(?:\[center\]\s*\[url=https://github\.com/z-ink/uploadrr\]\[img=\d+\]https://i\.ibb\.co/2NVWb0c/uploadrr\.webp\[/img\]\[/url\]\s*\[/center\])|(?:\[center\]\s*\[url=https://github\.com/edge20200/Only-Uploader\]Powered\s+by\s+Only-Uploader\[/url\]\s*\[/center\])|(?:\[center\]\s*\[url=/torrents\?perPage=\d+&name=[^\]]*\]\s*\[/url\]\s*\[/center\])|(?:\[center\]\s*(?:\[b\]\s*(?:\[size=\d+\])?brush(?:\[/size\])?\s*\[/b\]\s*)?This is an internal release which was first released exclusively on Aither\.\s*Cheers to all the Aither(?:\s+users)?\s*\[/center\])|(?:\[(?:center|right|align=right)\]\s*(?:\[url=[^\]]+\]\s*)?(?:\[size=[^\]]+\]\s*)?Created by(?:\s+[^[]*?)?\s*Upload Assistant(?:\s*\[/size\])?(?:\s*\[/url\])?\s*\[/(?:center|right|align)\])`, + ) +) + +// CleanDescription removes unsupported BHD formatting and uploader signatures, +// extracts unique image URLs, and applies the requested Framestor and Flux +// behavior. Image extraction preserves the first occurrence of each raw URL. +func CleanDescription(description string, options BBCodeOptions) bbcode.Report { + desc := bbcode.NormalizeNewlines(description) + report := bbcode.Report{} + imagelist := make([]bbcode.Image, 0) + + if options.Framestor { + if options.OnNFO != nil { + if err := options.OnNFO(desc); err != nil { + report.Notes = append(report.Notes, bbcode.Note{Kind: "nfo", Message: err.Error()}) + } + } + report.Artifacts = append(report.Artifacts, bbcode.Artifact{ + Name: "bhd.nfo", + Kind: "nfo", + Content: desc, + }) + } + + desc = bhdSizePattern.ReplaceAllString(desc, "") + desc = strings.ReplaceAll(desc, "[/size]", "") + desc = bhdBotSignature.ReplaceAllString(desc, "") + desc = strings.ReplaceAll(desc, "<", "/") + desc = strings.ReplaceAll(desc, "<", "\\") + + seen := make(map[string]struct{}) + appendImage := func(imgURL, webURL string) { + imgURL = strings.TrimSpace(imgURL) + if imgURL == "" { + return + } + key := strings.ToLower(imgURL) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + host := imagehost.ExtractHost(imgURL) + web := strings.TrimSpace(webURL) + if web == "" { + web = imgURL + } + rawURL := bbcode.NormalizeImageRawURL(imgURL) + imagelist = append(imagelist, bbcode.Image{ + ImgURL: imgURL, + RawURL: rawURL, + WebURL: web, + Host: host, + }) + } + + urlImgMatches := bhdURLImgPattern.FindAllStringSubmatch(desc, -1) + for _, match := range urlImgMatches { + if len(match) < 3 { + continue + } + appendImage(match[2], match[1]) + } + desc = bhdURLImgPattern.ReplaceAllString(desc, "") + + imgTagMatches := bhdImgTagPattern.FindAllStringSubmatch(desc, -1) + for _, match := range imgTagMatches { + if len(match) < 2 { + continue + } + appendImage(match[1], "") + } + desc = bhdImgTagPattern.ReplaceAllString(desc, "") + + looseImages := bhdLooseImg.FindAllString(desc, -1) + for _, imgURL := range looseImages { + appendImage(imgURL, "") + desc = strings.ReplaceAll(desc, imgURL, "") + } + + for _, image := range imagelist { + imgURL := regexp.QuoteMeta(image.ImgURL) + urlTag := regexp.MustCompile(`(?i)\[URL=` + imgURL + `\]\[/URL\]`) + desc = urlTag.ReplaceAllString(desc, "") + urlImg := regexp.MustCompile(`(?i)\[URL=` + imgURL + `\]\[img[^\]]*\]` + imgURL + `\[/img\]\[/URL\]`) + desc = urlImg.ReplaceAllString(desc, "") + } + + desc = regexp.MustCompile(`(?i)\[URL=[\s\S]*?\]\[/URL\]`).ReplaceAllString(desc, "") + desc = bhdTonemapNotice.ReplaceAllString(desc, "") + desc = bhdEmptyCenter.ReplaceAllString(desc, "") + desc = bhdEmptyAlign.ReplaceAllString(desc, "") + desc = bbcode.RemoveExtraLines(desc) + desc = strings.TrimSpace(desc) + + if options.Flux { + desc = strings.TrimRight(desc, " \t\n") + desc = strings.Trim(desc, "\n") + desc = regexp.MustCompile(`\n\n+`).ReplaceAllString(desc, "\n\n") + for strings.HasPrefix(desc, "\n") { + desc = strings.TrimPrefix(desc, "\n") + } + desc = strings.Trim(desc, "\n") + if strings.TrimSpace(strings.ReplaceAll(desc, "\n", "")) == "" { + return bbcode.Report{Images: imagelist} + } + report.Description = "[code]" + desc + "[/code]" + } else { + report.Description = desc + } + + if report.Description == "" { + report.Description = "" + } + if bbcode.IsOnlyTags(report.Description) { + return bbcode.Report{ + Images: imagelist, + Notes: report.Notes, + Artifacts: report.Artifacts, + } + } + return bbcode.Report{ + Description: report.Description, + Images: imagelist, + Notes: report.Notes, + Artifacts: report.Artifacts, + } +} diff --git a/internal/trackers/impl/standalone/bhd/data.go b/internal/trackers/impl/standalone/bhd/data.go new file mode 100644 index 00000000..7b7c8306 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/data.go @@ -0,0 +1,159 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/config" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +const minDataTokenLength = 25 + +type dataLookup struct { + cfg config.Config + http *http.Client + baseURL string +} + +// NewDataLookup returns a BHD lookup bound to cfg and httpClient. +func (d *Definition) NewDataLookup(cfg config.Config, httpClient *http.Client, _ api.Logger) trackers.DataLookup { + return &dataLookup{ + cfg: cfg, + http: httpClient, + baseURL: "https://beyond-hd.me/api/torrents", + } +} + +// Lookup resolves a BHD torrent by tracker ID or by folder/file name. Missing +// API or RSS credentials, an unusable search filter, non-success responses, and +// empty API results produce an empty result without an error. Description and +// image fields honor OnlyID and KeepImages independently. +func (l *dataLookup) Lookup(ctx context.Context, req trackers.DataLookupRequest) (trackers.DataLookupResult, error) { + cfg, apiKey := bhdConfig(l.cfg) + rssKey := strings.TrimSpace(cfg.BhdRSSKey) + if len(apiKey) < minDataTokenLength || len(rssKey) < minDataTokenLength { + return trackers.DataLookupResult{}, nil + } + endpoint := strings.TrimRight(l.baseURL, "/") + "/" + apiKey + payload := map[string]any{} + if id := strings.TrimSpace(req.TrackerID); id != "" { + payload["action"], payload["torrent_id"] = "details", id + } else { + payload["action"], payload["rsskey"] = "search", rssKey + hasFilter := false + if strings.TrimSpace(req.Meta.DiscType) != "" || len(req.Meta.FileList) != 1 { + payload["folder_name"], hasFilter = pathutil.Base(req.Meta.SourcePath), true + } else if name := strings.TrimSpace(req.SearchName); name != "" { + payload["file_name"], hasFilter = name, true + } + if !hasFilter { + return trackers.DataLookupResult{}, nil + } + } + first, err := l.requestFirst(ctx, endpoint, payload) + if err != nil || len(first) == 0 { + return trackers.DataLookupResult{}, err + } + result := trackers.DataLookupResult{TrackerID: req.TrackerID, IMDBID: bhdIMDBInt(first["imdb_id"])} + result.Category, result.TMDBID = parseTMDB(first["tmdb_id"]) + description := "" + if bhdString(first["description"]) == "1" { + torrentID := bhdString(first["id"]) + if torrentID == "" { + torrentID = strings.TrimSpace(req.TrackerID) + } + if torrentID != "" { + if body, requestErr := l.request(ctx, endpoint, map[string]any{"action": "description", "torrent_id": torrentID}); requestErr == nil { + description = bhdString(body["result"]) + } + } + } else { + description = bhdString(first["description"]) + } + if req.OnlyID && !req.KeepImages { + return result, nil + } + report := CleanDescription(description, BBCodeOptions{}) + if !req.OnlyID { + result.Description = strings.TrimSpace(report.Description) + } + if req.KeepImages { + result.Images = report.Images + } + if strings.TrimSpace(result.TrackerID) == "" { + result.TrackerID = bhdString(first["id"]) + } + return result, nil +} + +func (l *dataLookup) requestFirst(ctx context.Context, endpoint string, payload map[string]any) (map[string]any, error) { + body, err := l.request(ctx, endpoint, payload) + if err != nil || len(body) == 0 { + return nil, err + } + if items, ok := body["results"].([]any); ok && len(items) > 0 { + item, _ := items[0].(map[string]any) + return item, nil + } + item, _ := body["result"].(map[string]any) + return item, nil +} + +func (l *dataLookup) request(ctx context.Context, endpoint string, payload map[string]any) (map[string]any, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("trackerdata: bhd encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("trackerdata: bhd request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := l.http.Do(req) + if err != nil { + return nil, fmt.Errorf("trackerdata: bhd request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil + } + result := map[string]any{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("trackerdata: bhd decode: %w", err) + } + if bhdInt(result["status_code"]) == 0 { + return nil, nil + } + if success, ok := result["success"].(bool); ok && !success { + return nil, nil + } + return result, nil +} + +func parseTMDB(value any) (string, int) { + raw := strings.ToLower(bhdString(value)) + if raw == "" || raw == "0" { + return "", 0 + } + if after, ok := strings.CutPrefix(raw, "tv/"); ok { + return "TV", int(bhdInt(after)) + } + if after, ok := strings.CutPrefix(raw, "movie/"); ok { + return "MOVIE", int(bhdInt(after)) + } + return "", int(bhdInt(raw)) +} + +func bhdIMDBInt(value any) int { + return int(bhdInt(strings.TrimPrefix(strings.ToLower(bhdString(value)), "tt"))) +} diff --git a/internal/trackers/impl/standalone/bhd/data_test.go b/internal/trackers/impl/standalone/bhd/data_test.go new file mode 100644 index 00000000..a221fb8d --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/data_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDataLookup(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/bhd/") { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "status_code": 1, + "success": true, + "results": []any{map[string]any{ + "id": "99", + "imdb_id": "tt1234567", + "tmdb_id": "movie/765", + "description": "hello\n[url=https://pixhost.to/full/example][img]https://pixhost.to/example.png[/img][/url]", + }}, + }) + })) + defer server.Close() + + token := strings.Repeat("a", minDataTokenLength) + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"BHD": {APIKey: token, BhdRSSKey: token}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected BHD data lookup") + } + lookup.baseURL = server.URL + "/bhd" + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{ + Meta: api.UploadSubject{SourcePath: "/tmp/release"}, + SearchName: "release.mkv", + KeepImages: true, + }) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if result.TrackerID != "99" || result.IMDBID != 1234567 || result.TMDBID != 765 || result.Category != "MOVIE" || result.Description != "hello" || len(result.Images) != 1 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestDataLookupSkipsUnfilteredSearch(t *testing.T) { + t.Parallel() + requested := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requested = true + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + token := strings.Repeat("a", minDataTokenLength) + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"BHD": {APIKey: token, BhdRSSKey: token}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected BHD data lookup") + } + lookup.baseURL = server.URL + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{Meta: api.UploadSubject{SourcePath: `D:\TV\Example.Show.S04E01.2160p.WEB.h265-GRP.mkv`, FileList: []string{"Example.Show.S04E01.2160p.WEB.h265-GRP.mkv"}}, KeepImages: true}) + if err != nil || result.HasData() || requested { + t.Fatalf("err=%v data=%t requested=%t", err, result.HasData(), requested) + } +} diff --git a/internal/trackers/impl/standalone/bhd/definition.go b/internal/trackers/impl/standalone/bhd/definition.go new file mode 100644 index 00000000..64461c93 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/definition.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" +) + +func bannedGroups() []string { + return []string{ + "Sicario", "TOMMY", "x0r", "nikt0", "FGT", "d3g", "MeGusta", "YIFY", "tigole", "TEKNO3D", + "C4K", "RARBG", "4K4U", "EASports", "ReaLHD", "Telly", "AOC", "WKS", "SasukeducK", "CRUCiBLE", + "iFT", "ProRes", "MezRips", "Flights", "BiTOR", "iVy", "QxR", "SyncUP", "OFT", "TGS", + } +} + +// DataLookupConfigured reports whether BHD metadata lookup credentials are available. +func (d *Definition) DataLookupConfigured(cfg config.Config) bool { + entry, _ := bhdConfig(cfg) + return len(strings.TrimSpace(entry.APIKey)) >= minDataTokenLength && len(strings.TrimSpace(entry.BhdRSSKey)) >= minDataTokenLength +} + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + var err error + var assets trackers.DescriptionAssets + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) + } + if req.Logger != nil { + req.Logger.Warnf("trackers: BHD description assets failed: %v", err) + } + assets = trackers.DescriptionAssets{} + } + } + + description := buildDescription(req.Meta, req.Runtime.DescriptionConfig(), assets) + return trackers.DescriptionResult{ + Group: "bhd", + Description: description, + }, nil +} diff --git a/internal/trackers/impl/standalone/bhd/definition_test.go b/internal/trackers/impl/standalone/bhd/definition_test.go new file mode 100644 index 00000000..43f6f785 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/definition_test.go @@ -0,0 +1,688 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func (d *Definition) prepareDryRun(ctx context.Context, input trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + input.Intent = trackers.PreparationIntentDryRun + plan, failure := d.Prepare(ctx, input) + if failure != nil { + return api.TrackerDryRunEntry{}, failure + } + return plan.DryRun(), nil +} + +func (d *Definition) submit(ctx context.Context, input trackers.PreparationInput) (api.UploadSummary, error) { + input.Intent = trackers.PreparationIntentUpload + plan, failure := d.Prepare(ctx, input) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("submit prepared BHD test upload: %w", err) + } + return summary, nil +} + +func sendUpload(ctx context.Context, req trackers.PreparationInput, state uploadState) ([]byte, error) { + body, contentType, err := buildMultipartPayload(state.fields, state.mediaDump, state.torrentPath) + if err != nil { + return nil, err + } + _, responseBody, err := sendPreparedUpload(ctx, uploadEndpoint(req.TrackerConfig.APIKey), body, contentType) + return responseBody, err +} + +func TestDefinitionBuildUploadDryRunBuildsPayload(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + entry, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{ + TMDBID: 123, + IMDBID: 456, + Category: "TV", + }, + ReleaseName: "Movie.2025.1080p.WEB-DL.DD+5.1.H264-GRP", + Release: api.ReleaseInfo{Resolution: "1080p"}, + Type: "WEBDL", + Source: "WEB", + Audio: "DD+ 5.1", + HDR: "HDR10+ DV", + Edition: "Hybrid Director", + TVPack: true, + SeasonStr: "S00", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token", DraftDefault: true}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "BHD": {DraftDefault: true}, + }, + }, + }), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Payload["category_id"] != "2" { + t.Fatalf("expected tv category 2, got %q", entry.Payload["category_id"]) + } + if entry.Payload["type"] != "1080p" { + t.Fatalf("expected type 1080p, got %q", entry.Payload["type"]) + } + if entry.Payload["source"] != "WEB" { + t.Fatalf("expected source WEB, got %q", entry.Payload["source"]) + } + if entry.Payload["imdb_id"] != "456" { + t.Fatalf("expected numeric imdb_id, got %q", entry.Payload["imdb_id"]) + } + if entry.Payload["tmdb_id"] != "123" { + t.Fatalf("expected numeric tmdb_id, got %q", entry.Payload["tmdb_id"]) + } + if entry.Payload["live"] != "0" { + t.Fatalf("expected live=0 for draft default, got %q", entry.Payload["live"]) + } + if entry.Payload["pack"] != "1" { + t.Fatalf("expected pack=1, got %q", entry.Payload["pack"]) + } + if entry.Payload["special"] != "1" { + t.Fatalf("expected special=1, got %q", entry.Payload["special"]) + } + if entry.Payload["edition"] != "Director" { + t.Fatalf("expected title-cased edition, got %q", entry.Payload["edition"]) + } + if got := entry.Payload["tags"]; !strings.Contains(got, "WEBDL") || !strings.Contains(got, "HDR10+") || !strings.Contains(got, "DV") { + t.Fatalf("expected BHD tags in payload, got %q", got) + } +} + +func TestDefinitionBuildUploadDryRunPrerequisiteMessagesIncludeAction(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + req trackers.PreparationInput + wantCause string + wantAction string + }{ + { + name: "missing api key", + req: trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{Identity: api.ExternalIdentity{TMDBID: 123}}, + Logger: api.NopLogger{}, + }, + wantCause: "missing api_key", + wantAction: "configure the BHD api_key", + }, + { + name: "missing tmdb id", + req: trackers.PreparationInput{ + Tracker: "BHD", + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Logger: api.NopLogger{}, + }, + wantCause: "missing tmdb id", + wantAction: "refresh metadata or set a TMDB id", + }, + { + name: "missing mediainfo text", + req: trackers.PreparationInput{ + Tracker: "BHD", + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Meta: api.UploadSubject{Identity: api.ExternalIdentity{TMDBID: 123}}, + Logger: api.NopLogger{}, + }, + wantCause: "missing mediainfo text", + wantAction: "generate or attach MediaInfo", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := New().prepareDryRun(context.Background(), tc.req) + if err == nil { + t.Fatalf("expected prerequisite error") + } + message := err.Error() + if !strings.Contains(message, tc.wantCause) { + t.Fatalf("expected cause %q in error, got %q", tc.wantCause, message) + } + if !strings.Contains(message, tc.wantAction) { + t.Fatalf("expected action %q in error, got %q", tc.wantAction, message) + } + }) + } +} + +func TestDefinitionBuildUploadDryRunKeepsWaivableContainerDiagnosticOutOfPayloadReadiness(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + preview, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.avi"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{TMDBID: 123, Category: "MOVIE"}, + Type: "REMUX", + Source: "BLURAY", + Container: "avi", + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("prepare dry run: %v", err) + } + if preview.Status != "ready" { + t.Fatalf("dry-run status = %q, want ready", preview.Status) + } +} + +func TestResolveTypeRejectsBHD576iResolution(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{Release: api.ReleaseInfo{Resolution: "576i"}} + if got := resolveType(meta); got != "Other" { + t.Fatalf("expected BHD type Other for 576i, got %q", got) + } +} + +func TestResolveTypeRejectsHDDVDRemuxEvenWithUHD(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Type: "REMUX", + Source: "HDDVD", + UHD: "UHD", + } + if got := resolveType(meta); got != "Other" { + t.Fatalf("expected BHD type Other for HD-DVD remux, got %q", got) + } +} + +func TestDefinitionBuildUploadDryRunRejectsInvalidSource(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Example.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + _, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Example.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{TMDBID: 123, Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Type: "WEBDL", + Source: "CAM", + Container: "mkv", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Logger: api.NopLogger{}, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported source") { + t.Fatalf("expected unsupported source error, got %v", err) + } +} + +func TestUploadRetriesInvalidIMDb(t *testing.T) { + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/upload/token" { + http.NotFound(w, r) + return + } + if err := r.ParseMultipartForm(4 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(r.MultipartForm.File["mediainfo"]) != 1 { + t.Errorf("expected mediainfo file part") + return + } + if filename := r.MultipartForm.File["mediainfo"][0].Filename; filename != "upload" { + t.Errorf("expected Upload-Assistant-compatible mediainfo filename, got %q", filename) + return + } + if len(r.MultipartForm.File["file"]) != 1 { + t.Errorf("expected torrent file part") + return + } + if contentType := r.MultipartForm.File["file"][0].Header.Get("Content-Type"); contentType != "application/x-bittorrent" { + t.Errorf("expected Upload-Assistant-compatible torrent content type, got %q", contentType) + return + } + calls++ + imdbID := r.FormValue("imdb_id") + tmdbID := r.FormValue("tmdb_id") + if tmdbID != "123" { + t.Errorf("expected tmdb_id 123, got %q", tmdbID) + return + } + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + if imdbID != "456" { + t.Errorf("expected first imdb_id 456, got %q", imdbID) + return + } + _, _ = fmt.Fprint(w, `{"status_code":0,"status_message":"Invalid imdb_id"}`) + return + } + if imdbID != "1" { + t.Errorf("expected retried imdb_id 1, got %q", imdbID) + return + } + _, _ = fmt.Fprint(w, `{"status_code":1,"status_message":"https://beyond-hd.me/torrent/download/example.7890.torrent"}`) + })) + defer server.Close() + + originalBaseURL := bhdBaseURL + bhdBaseURL = server.URL + defer func() { bhdBaseURL = originalBaseURL }() + + summary, err := New().submit(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{ + TMDBID: 123, + IMDBID: 456, + Category: "MOVIE", + }, + ReleaseName: "Movie.2025.1080p.BluRay.DD+5.1.H264-GRP", + Release: api.ReleaseInfo{Resolution: "1080p"}, + Type: "REMUX", + Source: "BluRay", + Audio: "DD+ 5.1", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 upload attempts, got %d", calls) + } + if summary.Uploaded != 1 { + t.Fatalf("expected one upload, got %d", summary.Uploaded) + } + if len(summary.UploadedTorrents) != 1 || summary.UploadedTorrents[0].TorrentID != "7890" { + t.Fatalf("unexpected uploaded torrents: %+v", summary.UploadedTorrents) + } +} + +func TestSendUploadRejectsOversizedResponseBody(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "Example.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, strings.Repeat("x", int(bhdUploadResponseMaxBytes)+1)) + })) + defer server.Close() + + originalBaseURL := bhdBaseURL + bhdBaseURL = server.URL + defer func() { bhdBaseURL = originalBaseURL }() + + _, err := sendUpload(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }, uploadState{ + torrentPath: torrentPath, + fields: map[string]string{ + "name": "Example.Release.2026.1080p-GRP", + "category_id": "1", + "type": "1080p", + "source": "WEB", + "tmdb_id": "movie/123456", + "description": "Example description", + }, + }) + if err == nil || !strings.Contains(err.Error(), "response body exceeds") { + t.Fatalf("expected oversized response error, got %v", err) + } +} + +func TestSendUploadRejectsNonSuccessHTTPStatus(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "Example.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + tests := []struct { + name string + statusCode int + body string + wantDetail string + }{ + { + name: "valid-looking success body", + statusCode: http.StatusInternalServerError, + body: `{"status_code":1,"status_message":"https://beyond-hd.me/torrent/download/example.123456.torrent"}`, + }, + { + name: "ordinary error body", + statusCode: http.StatusUnprocessableEntity, + body: `{"message":"upload rejected"}`, + wantDetail: "upload rejected", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _, _ = fmt.Fprint(w, tt.body) + })) + defer server.Close() + + originalBaseURL := bhdBaseURL + bhdBaseURL = server.URL + defer func() { bhdBaseURL = originalBaseURL }() + + responseBody, err := sendUpload(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }, uploadState{ + torrentPath: torrentPath, + fields: map[string]string{ + "name": "Example.Release.2026.1080p-GRP", + "category_id": "1", + "type": "1080p", + "source": "WEB", + "tmdb_id": "movie/123456", + "description": "Example description", + }, + }) + if err == nil { + t.Fatal("expected non-success HTTP status error") + } + if !strings.Contains(err.Error(), fmt.Sprintf("status=%d", tt.statusCode)) { + t.Fatal("expected HTTP status in upload error") + } + if tt.wantDetail != "" && !strings.Contains(err.Error(), tt.wantDetail) { + t.Fatal("expected redacted response detail in upload error") + } + if string(responseBody) != tt.body { + t.Fatal("expected bounded response body to be returned") + } + }) + } +} + +func TestWriteFailureArtifactPreservesRepeatedFailures(t *testing.T) { + tmp := t.TempDir() + req := trackers.PreparationInput{ + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Example.Release.2026.1080p-GRP.mkv"), + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "upbrr.db")}, + }), + } + + firstPayload := []byte(`{"message":"first failure"}`) + firstPath, err := writeFailureArtifact(req, firstPayload, "upload_failure") + if err != nil { + t.Fatalf("write first failure artifact: %v", err) + } + secondPayload := []byte(`{"message":"second failure"}`) + secondPath, err := writeFailureArtifact(req, secondPayload, "upload_failure") + if err != nil { + t.Fatalf("write second failure artifact: %v", err) + } + + firstInfo, err := os.Stat(firstPath) + if err != nil { + t.Fatalf("stat first failure artifact: %v", err) + } + secondInfo, err := os.Stat(secondPath) + if err != nil { + t.Fatalf("stat second failure artifact: %v", err) + } + if os.SameFile(firstInfo, secondInfo) { + t.Fatal("repeated failures must use distinct artifacts") + } + firstStored, err := os.ReadFile(firstPath) + if err != nil { + t.Fatalf("read first failure artifact: %v", err) + } + secondStored, err := os.ReadFile(secondPath) + if err != nil { + t.Fatalf("read second failure artifact: %v", err) + } + if string(firstStored) != string(firstPayload) || string(secondStored) != string(secondPayload) { + t.Fatal("repeated failure artifacts did not preserve both payloads") + } +} + +func TestDefinitionBuildDescriptionUsesProvidedAssets(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: `[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]`, + Screenshots: []api.ScreenshotImage{{ + RawURL: "https://img.hdbits.org/full.jpg", + ImgURL: "https://t.hdbits.org/thumb.jpg", + WebURL: "https://img.hdbits.org/page", + }}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(result.Description, "Uploaded by upbrr") != 1 { + t.Fatalf("expected single signature, got %q", result.Description) + } + if !strings.Contains(result.Description, "[img width=350]https://img.hdbits.org/full.jpg[/img]") { + t.Fatalf("expected raw image url to be used, got %q", result.Description) + } +} + +func TestBuildScreenshotSectionUsesTwoImagesPerRow(t *testing.T) { + images := []api.ScreenshotImage{ + {RawURL: "https://img.example/1.png", WebURL: "https://img.example/1"}, + {RawURL: "https://img.example/2.png", WebURL: "https://img.example/2"}, + {RawURL: "https://img.example/3.png", WebURL: "https://img.example/3"}, + {RawURL: "https://img.example/4.png", WebURL: "https://img.example/4"}, + } + + got := buildScreenshotSection(images, len(images)) + want := strings.Join([]string{ + `[align=center][url=https://img.example/1][img width=350]https://img.example/1.png[/img][/url] [url=https://img.example/2][img width=350]https://img.example/2.png[/img][/url]`, + ``, + `[url=https://img.example/3][img width=350]https://img.example/3.png[/img][/url] [url=https://img.example/4][img width=350]https://img.example/4.png[/img][/url][/align]`, + }, "\n") + if got != want { + t.Fatalf("unexpected BHD screenshot layout: %q", got) + } +} + +func TestDefinitionBuildDescriptionStripsLegacyCreatedFooter(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: strings.Join([]string{ + "Body", + `[align=right][url=https://github.com/autobrr/upbrr]Created by upbrr[/url][/align]`, + }, "\n\n"), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result.Description, "Created by upbrr") { + t.Fatalf("expected legacy footer stripped, got %q", result.Description) + } + if strings.Count(result.Description, "Uploaded by upbrr") != 1 { + t.Fatalf("expected single current footer, got %q", result.Description) + } + if !strings.Contains(result.Description, "Body") { + t.Fatalf("expected body preserved, got %q", result.Description) + } +} + +func TestDefinitionBuildDescriptionDoesNotRestoreRawImagesOnlyBody(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: strings.Join([]string{ + `[url=https://example.com/page][img]https://example.com/full.jpg[/img][/url]`, + `[right]Created by Upload Assistant[/right]`, + }, "\n\n"), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result.Description, "Upload Assistant") { + t.Fatalf("expected bot footer stripped, got %q", result.Description) + } + if strings.Count(result.Description, "https://example.com/full.jpg") != 1 { + t.Fatalf("expected one screenshot instance, got %q", result.Description) + } + if strings.Count(result.Description, "Uploaded by upbrr") != 1 { + t.Fatalf("expected single current footer, got %q", result.Description) + } +} + +func TestDefinitionBuildDescriptionStripsRightFormUpbrrFooter(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: strings.Join([]string{ + "Body", + `[right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/right]`, + }, "\n\n"), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(result.Description, "Uploaded by upbrr") != 1 { + t.Fatalf("expected only current BHD footer, got %q", result.Description) + } + if strings.Contains(result.Description, "[right]") || strings.Contains(result.Description, "[size=10]upbrr[/size]") { + t.Fatalf("expected right-form footer stripped, got %q", result.Description) + } + if !strings.Contains(result.Description, "Body") { + t.Fatalf("expected body preserved, got %q", result.Description) + } +} + +func TestDefinitionBuildDescriptionDoesNotRestoreBotOnlyBody(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "BHD", + Meta: api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: `[right]Created by Upload Assistant[/right]`, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(result.Description, "Upload Assistant") { + t.Fatalf("expected bot-only body stripped, got %q", result.Description) + } + if strings.Count(result.Description, "Uploaded by upbrr") != 1 { + t.Fatalf("expected single current footer, got %q", result.Description) + } +} diff --git a/internal/trackers/impl/standalone/bhd/description.go b/internal/trackers/impl/standalone/bhd/description.go new file mode 100644 index 00000000..ca967c6a --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/description.go @@ -0,0 +1,191 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "fmt" + "os" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/metadata/metautil" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func buildDescription(meta api.UploadSubject, cfg config.Config, assets trackers.DescriptionAssets) string { + base := strings.TrimSpace(assets.Description) + if assets.Final { + return base + } + + cleaned := CleanDescription(base, BBCodeOptions{ + Framestor: hasGroup(meta.Tag, "framestor"), + Flux: hasGroup(meta.Tag, "flux"), + }) + + descriptionBody := strings.TrimSpace(cleaned.Description) + descriptionBody = stripUASignature(descriptionBody) + descriptionBody = strings.ReplaceAll(descriptionBody, "[img]", "[img width=300]") + + images := assets.Screenshots + if len(images) == 0 { + images = screenshotsFromReport(cleaned.Images) + } + + parts := make([]string, 0, 5) + if discSection := buildDiscSection(meta, cfg.MainSettings.DBPath); discSection != "" { + parts = append(parts, discSection) + } + if descriptionBody != "" { + parts = append(parts, descriptionBody) + } + if screenshots := buildScreenshotSection(images, maxInt(1, meta.Options.Screens)); screenshots != "" { + parts = append(parts, screenshots) + } + parts = append(parts, `[align=right][url=https://github.com/autobrr/upbrr]Uploaded by upbrr[/url][/align]`) + return strings.TrimSpace(strings.Join(parts, "\n\n")) +} + +func buildDiscSection(meta api.UploadSubject, dbPath string) string { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "DVD": + media := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.DVDVOBMediaInfoText), readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath))) + if media == "" { + return "" + } + return fmt.Sprintf("[spoiler=VOB MediaInfo][code]%s[/code][/spoiler]", media) + case "BDMV": + text := readBDInfoNoErr(dbPath, meta) + if text == "" { + return "" + } + return fmt.Sprintf("[spoiler=BDINFO][code]%s[/code][/spoiler]", text) + default: + return "" + } +} + +func buildScreenshotSection(images []api.ScreenshotImage, limit int) string { + if len(images) == 0 || limit <= 0 { + return "" + } + + var section strings.Builder + section.WriteString("[align=center]") + count := 0 + for _, image := range images { + if count >= limit { + break + } + imgURL := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(image.RawURL), strings.TrimSpace(image.ImgURL)) + webURL := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(image.WebURL), strings.TrimSpace(image.RawURL), imgURL) + if imgURL == "" || webURL == "" { + continue + } + if count > 0 { + if count%2 == 0 { + section.WriteString("\n\n") + } else { + section.WriteByte(' ') + } + } + line := fmt.Sprintf("[url=%s][img width=350]%s[/img][/url]", webURL, imgURL) + section.WriteString(line) + count++ + } + if count == 0 { + return "" + } + section.WriteString("[/align]") + return section.String() +} + +func screenshotsFromReport(images []bbcode.Image) []api.ScreenshotImage { + if len(images) == 0 { + return nil + } + out := make([]api.ScreenshotImage, 0, len(images)) + for idx, image := range images { + imgURL := strings.TrimSpace(image.ImgURL) + rawURL := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(image.RawURL), imgURL) + webURL := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(image.WebURL), rawURL, imgURL) + if imgURL == "" && rawURL == "" { + continue + } + out = append(out, api.ScreenshotImage{ + Index: idx, + Host: strings.TrimSpace(image.Host), + ImgURL: metautil.FirstNonEmptyTrimmed(imgURL, rawURL), + RawURL: rawURL, + WebURL: webURL, + }) + } + return out +} + +func stripUASignature(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + signatures := []string{ + `[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]`, + `[align=right][url=https://github.com/autobrr/upbrr]upbrr[/url][/align]`, + `[align=right][url=https://github.com/autobrr/upbrr]Created by upbrr[/url][/align]`, + `[align=right]Created by upbrr[/align]`, + `[align=right][url=https://github.com/autobrr/upbrr]Uploaded by upbrr[/url][/align]`, + `[right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/right]`, + `[right][url=https://github.com/autobrr/upbrr]upbrr[/url][/right]`, + `[right][url=https://github.com/autobrr/upbrr]Created by upbrr[/url][/right]`, + `[right]Created by upbrr[/right]`, + `[right][url=https://github.com/autobrr/upbrr]Uploaded by upbrr[/url][/right]`, + } + for _, signature := range signatures { + trimmed = strings.TrimSpace(strings.ReplaceAll(trimmed, signature, "")) + } + return trimmed +} + +func readTextFileNoErr(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + payload, err := os.ReadFile(trimmed) + if err != nil { + return "" + } + return string(payload) +} + +func readBDInfoNoErr(dbPath string, meta api.UploadSubject) string { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "" + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "" + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "" + } + return readTextFileNoErr(paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists))) +} + +func hasGroup(tag string, name string) bool { + trimmed := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(tag, "-"))) + return trimmed == strings.ToLower(strings.TrimSpace(name)) +} + +func maxInt(left int, right int) int { + if left > right { + return left + } + return right +} diff --git a/internal/trackers/impl/standalone/bhd/dupe.go b/internal/trackers/impl/standalone/bhd/dupe.go new file mode 100644 index 00000000..b3910e11 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/dupe.go @@ -0,0 +1,262 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +var seasonPattern = regexp.MustCompile(`(?i)S(\d{1,2})`) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + baseURL string +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + baseURL: "https://beyond-hd.me/api/torrents/", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + cfg, apiKey := bhdConfig(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + tmdbID, imdbID := meta.Identity.TMDBID, bhdIMDB(meta.Identity.IMDBID) + if tmdbID == 0 && imdbID == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing tmdb/imdb id for BHD dupe search", nil) + } + category, tmdbPrefix := "Movies", "movie" + if strings.EqualFold(string(meta.Identity.Category), "TV") { + category, tmdbPrefix = "TV", "tv" + } + payload := map[string]any{"action": "search", "categories": category} + if searchType, ok := dupeSearchType(meta); ok { + payload["types"] = searchType + } else { + payload["types"] = nil + } + if dupeIsSD(meta) { + payload["categories"], payload["types"] = nil, nil + } + if tmdbID != 0 { + payload["tmdb_id"] = tmdbPrefix + "/" + strconv.Itoa(tmdbID) + } else { + payload["imdb_id"] = imdbID + } + if season := bhdSeason(meta); season != "" && category == "TV" { + payload["search"] = season + } + if rss := strings.TrimSpace(cfg.BhdRSSKey); rss != "" { + payload["rsskey"] = rss + } + body, err := json.Marshal(payload) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "BHD request failed", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+apiKey, bytes.NewReader(body)) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "BHD request failed", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "BHD request failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "BHD search failed", nil) + } + var decoded map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil || len(decoded) == 0 { + return dupe.Failed(dupe.FailureResponseParse, "BHD search failed", err) + } + if bhdInt(decoded["status_code"]) == 0 { + return dupe.Failed(dupe.FailureResponseStatus, "BHD api rejected search", nil) + } + return dupe.Resolved(bhdEntries(decoded), nil) +} + +func bhdEntries(payload map[string]any) []api.DupeEntry { + results, _ := payload["results"].([]any) + entries := make([]api.DupeEntry, 0, len(results)) + for _, raw := range results { + item, ok := raw.(map[string]any) + if !ok { + continue + } + entry := api.DupeEntry{Name: bhdString(item["name"]), Link: bhdString(item["url"])} + if size := bhdInt(item["size"]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + if bhdInt(item["dv"]) == 1 { + entry.Flags = append(entry.Flags, "DV") + } + if bhdInt(item["hdr10"]) == 1 || bhdInt(item["hdr10+"]) == 1 { + entry.Flags = append(entry.Flags, "HDR") + } + entries = append(entries, entry) + } + return entries +} + +func bhdConfig(cfg config.Config) (config.TrackerConfig, string) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "BHD") { + return entry, strings.TrimSpace(entry.APIKey) + } + } + return config.TrackerConfig{}, "" +} + +func bhdSeason(meta api.DuplicateSubject) string { + if meta.ReleaseNameOverrides.Season != nil { + return normalizeBHDSeason(*meta.ReleaseNameOverrides.Season) + } + match := seasonPattern.FindStringSubmatch(meta.ReleaseName) + if len(match) == 2 { + return normalizeBHDSeason(match[1]) + } + return "" +} + +func dupeSearchType(meta api.DuplicateSubject) (string, bool) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + return "", false + } + return dupeType(meta), true +} + +func dupeType(meta api.DuplicateSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + size := 100 + for _, candidate := range []int{25, 50, 66, 100} { + if meta.SourceSize > 0 && meta.SourceSize < int64(candidate)<<30 { + size = candidate + break + } + } + if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && size != 25 { + if size == 50 || size == 66 || size == 100 { + return fmt.Sprintf("UHD %d", size) + } + return "Other" + } + if size == 25 || size == 50 { + return fmt.Sprintf("BD %d", size) + } + return "Other" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + upper := strings.ToUpper(strings.TrimSpace(meta.Release.Size)) + switch { + case strings.Contains(upper, "DVD5"): + return "DVD 5" + case strings.Contains(upper, "DVD9"): + return "DVD 9" + default: + return "Other" + } + } + if strings.EqualFold(strings.TrimSpace(firstNonEmpty(meta.Type, meta.Release.Type)), "REMUX") { + source := firstNonEmpty(meta.Source, meta.Release.Source) + switch { + case isHDDVDSource(source): + return "Other" + case strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD"): + return "UHD Remux" + case isDVDSource(source): + return "DVD Remux" + case strings.EqualFold(strings.TrimSpace(source), "BluRay"), strings.EqualFold(strings.TrimSpace(source), "Blu-ray"): + return "BD Remux" + default: + return "Other" + } + } + resolution := normalizeResolution(meta.Release.Resolution) + switch resolution { + case "2160p", "1080p", "1080i", "720p", "576p", "540p", "480p": + return resolution + default: + return "Other" + } +} + +func dupeIsSD(meta api.DuplicateSubject) bool { + resolution := normalizeResolution(meta.Release.Resolution) + return strings.Contains(resolution, "480") || strings.Contains(resolution, "540") || strings.Contains(resolution, "576") +} + +func normalizeBHDSeason(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if strings.HasPrefix(strings.ToUpper(trimmed), "S") { + return strings.ToUpper(trimmed) + } + if number, err := strconv.Atoi(trimmed); err == nil { + return "S" + strconv.Itoa(number) + } + return strings.ToUpper(trimmed) +} + +func bhdIMDB(id int) string { + if id == 0 { + return "" + } + return fmt.Sprintf("tt%07d", id) +} + +func bhdString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func bhdInt(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case int: + return int64(typed) + case int64: + return typed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/bhd/dupe_test.go b/internal/trackers/impl/standalone/bhd/dupe_test.go new file mode 100644 index 00000000..c37707d4 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/dupe_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +type bhdRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f bhdRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func bhdStringFromAny(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func TestBHDSearchUsesExternalIDs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + meta api.DuplicateSubject + wantTMDBID string + wantIMDBID string + wantCategory string + wantType string + wantNilType bool + wantNilCat bool + }{ + { + name: "tv tmdb id", + meta: api.DuplicateSubject{ + SourcePath: "source", + Identity: api.ExternalIdentity{TMDBID: 123, Category: "TV"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + wantTMDBID: "tv/123", + wantCategory: "TV", + wantType: "1080p", + }, + { + name: "movie tmdb id", + meta: api.DuplicateSubject{ + SourcePath: "source", + Identity: api.ExternalIdentity{TMDBID: 234, Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + wantTMDBID: "movie/234", + wantCategory: "Movies", + wantType: "1080p", + }, + { + name: "tmdb takes precedence over imdb", + meta: api.DuplicateSubject{ + SourcePath: "source", + Identity: api.ExternalIdentity{ + TMDBID: 123, + IMDBID: 7654321, + Category: "MOVIE", + }, + Release: api.ReleaseInfo{Resolution: "2160p"}, + }, + wantTMDBID: "movie/123", + wantCategory: "Movies", + wantType: "2160p", + }, + { + name: "imdb id", + meta: api.DuplicateSubject{ + SourcePath: "source", + Identity: api.ExternalIdentity{IMDBID: 1234567, Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + wantIMDBID: "tt1234567", + wantCategory: "Movies", + wantType: "1080p", + }, + { + name: "sd clears category and type filters", + meta: api.DuplicateSubject{ + SourcePath: "source", + Identity: api.ExternalIdentity{TMDBID: 123, Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "576p"}, + }, + wantTMDBID: "movie/123", + wantNilType: true, + wantNilCat: true, + }, + { + name: "dvd clears type filter", + meta: api.DuplicateSubject{ + SourcePath: "source", + DiscType: "DVD", + Identity: api.ExternalIdentity{TMDBID: 123, Category: "MOVIE"}, + Release: api.ReleaseInfo{Size: "DVD9"}, + }, + wantTMDBID: "movie/123", + wantCategory: "Movies", + wantNilType: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var payload map[string]any + client := &http.Client{Transport: bhdRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString( + `{"status_code":1,"results":[{"name":"Example.Release.2026.1080p-GRP"}]}`, + )), + Header: make(http.Header), + }, nil + })} + cfg := config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "BHD": {APIKey: "placeholder"}, + }}} + handler := dupe.NewAdapter(New(), "BHD", cfg, client, api.NopLogger{}) + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), tc.meta)) + if err != nil { + t.Fatalf("search failed: %v", err) + } + if len(notes) != 0 { + t.Fatalf("unexpected display notes: %v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected one result, got %#v", entries) + } + if got := bhdStringFromAny(payload["tmdb_id"]); got != tc.wantTMDBID { + t.Fatalf("expected tmdb_id %q, got %q", tc.wantTMDBID, got) + } + if got := bhdStringFromAny(payload["imdb_id"]); got != tc.wantIMDBID { + t.Fatalf("expected imdb_id %q, got %q", tc.wantIMDBID, got) + } + if tc.wantNilCat { + if value, ok := payload["categories"]; !ok || value != nil { + t.Fatalf("expected nil category filter, got %#v", value) + } + } else if got := bhdStringFromAny(payload["categories"]); got != tc.wantCategory { + t.Fatalf("expected category %q, got %q", tc.wantCategory, got) + } + if tc.wantNilType { + if value, ok := payload["types"]; !ok || value != nil { + t.Fatalf("expected nil type filter, got %#v", value) + } + } else if got := bhdStringFromAny(payload["types"]); got != tc.wantType { + t.Fatalf("expected type %q, got %q", tc.wantType, got) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/bhd/metadata.go b/internal/trackers/impl/standalone/bhd/metadata.go new file mode 100644 index 00000000..56eaea88 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/metadata.go @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "fmt" + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +// Source maps local source labels to BHD's upload source enum. +// The boolean is false when BHD has no supported source value for the input. +func Source(source string) (string, bool) { + switch strings.ToUpper(strings.TrimSpace(source)) { + case "BLURAY", "BLU-RAY": + return "Blu-ray", true + case "HDDVD", "HD DVD", "HD-DVD": + return "HD-DVD", true + case "WEB", "WEB-DL", "WEBDL", "WEBRIP": + return "WEB", true + case "HDTV", "UHDTV": + return "HDTV", true + case "NTSC", "PAL", "NTSC DVD", "PAL DVD", "DVD": + return "DVD", true + default: + return "", false + } +} + +// SourceForMetadata maps the canonical prepared source. +func SourceForMetadata(meta api.UploadSubject) (string, bool) { + return Source(meta.Source) +} + +// Type maps prepared metadata to BHD's upload/search type enum. Unknown or +// unsupported combinations return Other, including direct 576i releases and +// HD-DVD remuxes. +func Type(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + size := 100 + for _, candidate := range []int{25, 50, 66, 100} { + if meta.SourceSize > 0 && meta.SourceSize < int64(candidate)<<30 { + size = candidate + break + } + } + if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && size != 25 { + if size == 50 || size == 66 || size == 100 { + return fmt.Sprintf("UHD %d", size) + } + return "Other" + } + if size == 25 || size == 50 { + return fmt.Sprintf("BD %d", size) + } + return "Other" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + upper := strings.ToUpper(strings.TrimSpace(meta.Release.Size)) + switch { + case strings.Contains(upper, "DVD5"): + return "DVD 5" + case strings.Contains(upper, "DVD9"): + return "DVD 9" + default: + return "Other" + } + } + if strings.EqualFold(strings.TrimSpace(firstNonEmpty(meta.Type, meta.Release.Type)), "REMUX") { + source := firstNonEmpty(meta.Source, meta.Release.Source) + switch { + case isHDDVDSource(source): + return "Other" + case strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD"): + return "UHD Remux" + case isDVDSource(source): + return "DVD Remux" + case strings.EqualFold(strings.TrimSpace(source), "BluRay"), strings.EqualFold(strings.TrimSpace(source), "Blu-ray"): + return "BD Remux" + default: + return "Other" + } + } + resolution := normalizeResolution(meta.Release.Resolution) + switch resolution { + case "2160p", "1080p", "1080i", "720p", "576p", "540p", "480p": + return resolution + default: + return "Other" + } +} + +// SearchType returns the BHD search type filter. The boolean is false for DVD +// searches because BHD matching is broader when the type filter is omitted. +func SearchType(meta api.UploadSubject) (string, bool) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + return "", false + } + return Type(meta), true +} + +// IsSD reports whether BHD should mark the release as SD or omit HD-only dupe +// filters. +func IsSD(meta api.UploadSubject) bool { + resolution := normalizeResolution(meta.Release.Resolution) + return strings.Contains(resolution, "480") || strings.Contains(resolution, "540") || strings.Contains(resolution, "576") +} + +// IsDVDSource reports whether a source label maps to BHD's DVD source. +func IsDVDSource(source string) bool { + return isDVDSource(source) +} + +func normalizeResolution(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func isDVDSource(source string) bool { + upper := strings.ToUpper(strings.TrimSpace(source)) + return upper == "PAL DVD" || upper == "NTSC DVD" || upper == "DVD" || upper == "PAL" || upper == "NTSC" +} + +func isHDDVDSource(source string) bool { + upper := strings.ToUpper(strings.TrimSpace(source)) + return upper == "HDDVD" || upper == "HD DVD" || upper == "HD-DVD" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/trackers/impl/standalone/bhd/metadata_test.go b/internal/trackers/impl/standalone/bhd/metadata_test.go new file mode 100644 index 00000000..0c372e16 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/metadata_test.go @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestSourceForMetadataDoesNotFallbackToParsedSource(t *testing.T) { + t.Parallel() + + got, ok := SourceForMetadata(api.UploadSubject{ + Release: api.ReleaseInfo{Source: "WEB"}, + }) + if ok || got != "" { + t.Fatalf("expected missing canonical source to remain unsupported, got source=%q ok=%t", got, ok) + } +} diff --git a/internal/trackers/impl/standalone/bhd/profile.go b/internal/trackers/impl/standalone/bhd/profile.go new file mode 100644 index 00000000..70a31592 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/profile.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns BHD identity, preparation, dupe, rules, bans, and policies. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "BHD", + BaseURL: bhdBaseURL, + DescriptionGroup: "bhd", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + BannedGroups: bannedGroups(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "BHD"}, + AudioPolicy: &trackers.AudioPolicy{BlockEnglishOriginalWithForeign: true}, + ImageHostPolicy: &trackers.ImageHostPolicy{AllowedHosts: []string{"imgbox", "imgbb", "pixhost", "bhd", "bam"}}, + DupePolicy: &trackers.DupePolicy{ + MatchAggregateSize: true, + NormalizeDDPlusName: true, + SDMatchesHD: true, + CompareDVDResolution: true, + AllowSizeVariance1080: true, + }, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeMovie, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://beyond-hd.me", "tracker.beyond-hd.me"}, + CommentURLPatterns: []string{"https://beyond-hd.me"}, + DetailIDPattern: "details/(\\d+)", + }, + } +} + +// Definition extends the shared standalone definition with BHD data lookup. +type Definition struct{ *standalone.Definition } + +// New returns a fresh BHD definition from its tracker-local profile. +func New() *Definition { return &Definition{Definition: standalone.MustNew(Profile())} } diff --git a/internal/trackers/impl/standalone/bhd/rules.go b/internal/trackers/impl/standalone/bhd/rules.go new file mode 100644 index 00000000..614847e2 --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/rules.go @@ -0,0 +1,44 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func rules() *trackers.RuleSet { + return &trackers.RuleSet{ + RequireValidMISetting: true, + BlockAdult: true, + AdultMessage: "Porn/xxx is not allowed at BHD.", + Check: checkRequirements, + } +} + +func checkRequirements(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "REMUX", "ENCODE", "WEBDL", "WEBRIP": + container := strings.ToLower(strings.TrimSpace(meta.Container)) + if container != "" && container != "mkv" && container != "mp4" { + return []api.RuleFailure{trackers.NewRuleFailure( + "container", + fmt.Sprintf( + "Container %q is not allowed for %s. Only MKV and MP4 are permitted.", + meta.Container, + strings.ToUpper(strings.TrimSpace(meta.Type)), + ), + api.RuleDispositionStrict, + )}, nil + } + } + return nil, nil +} diff --git a/internal/trackers/impl/standalone/bhd/upload.go b/internal/trackers/impl/standalone/bhd/upload.go new file mode 100644 index 00000000..cd94d4ea --- /dev/null +++ b/internal/trackers/impl/standalone/bhd/upload.go @@ -0,0 +1,622 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + bhdBaseURL = "https://beyond-hd.me" + bhdTorrentIDPattern = regexp.MustCompile(`https://beyond-hd\.me/torrent/download/[^\"'\s]+?\.(\d+)\.`) + bhdInvalidIMDbPattern = regexp.MustCompile(`(?i)^invalid imdb_id`) +) + +const bhdUploadResponseMaxBytes = commonhttp.DefaultResponsePreviewBytes + +type uploadState struct { + torrentPath string + mediaDump string + description string + fields map[string]string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state, req) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + body, contentType, err := buildMultipartPayload(state.fields, state.mediaDump, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + fallbackFields := maps.Clone(state.fields) + fallbackFields["imdb_id"] = "1" + fallbackBody, fallbackContentType, err := buildMultipartPayload(fallbackFields, state.mediaDump, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + endpoint := uploadEndpoint(strings.TrimSpace(req.TrackerConfig.APIKey)) + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "BHD") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload( + submitCtx, req, state, endpoint, body, contentType, fallbackBody, fallbackContentType, announceURL, artifactPath, + ) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + endpoint string, + body []byte, + contentType string, + fallbackBody []byte, + fallbackContentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + response, responseBody, err := sendPreparedUpload(ctx, endpoint, body, contentType) + if err != nil { + return api.UploadSummary{}, err + } + if response.StatusCode == 0 && bhdInvalidIMDbPattern.MatchString(response.StatusMessage) { + response, responseBody, err = sendPreparedUpload(ctx, endpoint, fallbackBody, fallbackContentType) + if err != nil { + return api.UploadSummary{}, err + } + } + if response.StatusCode == 0 { + artifactPath, artifactErr := writeFailureArtifact(req, responseBody, "upload_failure") + if artifactErr != nil && req.Logger != nil { + req.Logger.Warnf("trackers: BHD failure artifact write failed: %v", artifactErr) + } + if artifactPath != "" && req.Logger != nil { + req.Logger.Warnf("trackers: BHD upload failure artifact saved to %s", artifactPath) + } + message := commonhttp.ExtractHTTPErrorDetail(responseBody) + if message == "" { + message = commonhttp.RedactErrorDetail(response.StatusMessage) + } + if message == "" { + message = "upload failed" + } + return api.UploadSummary{}, fmt.Errorf("trackers: BHD api error: %s", message) + } + + torrentID := extractTorrentID(response.StatusMessage) + if torrentID == "" { + return api.UploadSummary{}, errors.New("trackers: BHD upload succeeded but torrent id was not returned") + } + torrentURL := strings.TrimRight(bhdBaseURL, "/") + "/details/" + torrentID + downloadURL := strings.TrimRight(bhdBaseURL, "/") + "/torrent/download/" + torrentID + + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, "BHD"); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "BHD", + TorrentID: torrentID, + DownloadURL: downloadURL, + TorrentURL: torrentURL, + TorrentPath: artifactPath, + }}, + }, nil +} + +func buildUploadPreview(state uploadState, req trackers.PreparationInput) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "BHD", + ReleaseName: state.fields["name"], + DescriptionGroup: "bhd", + Description: state.description, + Endpoint: strings.TrimRight(bhdBaseURL, "/") + "/api/upload/[redacted]", + Payload: state.fields, + Files: []api.TrackerDryRunFile{ + { + Field: "mediainfo", + Path: resolveMediaPath(req.Meta, req.Runtime.DBPath), + Present: strings.TrimSpace(state.mediaDump) != "", + }, + { + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }, + }, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + select { + case <-ctx.Done(): + return uploadState{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: BHD missing api_key; configure the BHD api_key in tracker settings before uploading") + } + if req.Meta.Identity.TMDBID == 0 { + return uploadState{}, errors.New("trackers: BHD missing tmdb id; refresh metadata or set a TMDB id before uploading") + } + var err error + var assets trackers.DescriptionAssets + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + } + description := buildDescription(req.Meta, req.Runtime.DescriptionConfig(), assets) + mediaDump, err := resolveMediaDump(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, err + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + + tags := resolveTags(req.Meta) + customEdition, edition := resolveEdition(req.Meta, tags) + source, ok := resolveSource(req.Meta) + if !ok { + return uploadState{}, fmt.Errorf("trackers: BHD unsupported source %q", req.Meta.Source) + } + fields := map[string]string{ + "name": resolveUploadName(req.Meta), + "category_id": resolveCategoryID(req.Meta), + "type": resolveType(req.Meta), + "source": source, + "imdb_id": resolveIMDbID(req.Meta), + "tmdb_id": resolveTMDBID(req.Meta), + "description": description, + "anon": resolveAnon(req.TrackerConfig), + "sd": boolFlag(isSD(req.Meta)), + "live": resolveLive(req.TrackerConfig), + } + if req.Runtime.Internal { + fields["internal"] = "1" + } + if req.Meta.TVPack { + fields["pack"] = "1" + } + if strings.EqualFold(strings.TrimSpace(req.Meta.SeasonStr), "S00") { + fields["special"] = "1" + } + if region := resolveRegion(req.Meta.Region); region != "" { + fields["region"] = region + } + if customEdition { + fields["custom_edition"] = edition + } else if edition != "" { + fields["edition"] = edition + } + if len(tags) > 0 { + fields["tags"] = strings.Join(tags, ",") + } + + return uploadState{ + torrentPath: torrentPath, + mediaDump: mediaDump, + description: description, + fields: fields, + }, nil +} + +func sendPreparedUpload(ctx context.Context, endpoint string, body []byte, contentType string) (uploadResponse, []byte, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHD request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHD upload request: %w", err) + } + defer resp.Body.Close() + + responseBody, err := readUploadResponseBody(resp.Body) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHD read response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return uploadResponse{}, responseBody, commonhttp.UploadHTTPError("BHD", resp.StatusCode, responseBody) + } + decoded := uploadResponse{} + if len(responseBody) > 0 { + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return uploadResponse{}, responseBody, fmt.Errorf("trackers: BHD decode response: %w", err) + } + } + return decoded, responseBody, nil +} + +func readUploadResponseBody(r io.Reader) ([]byte, error) { + responseBody, err := io.ReadAll(io.LimitReader(r, bhdUploadResponseMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read upload response body: %w", err) + } + if int64(len(responseBody)) > bhdUploadResponseMaxBytes { + return nil, fmt.Errorf("response body exceeds %d bytes", bhdUploadResponseMaxBytes) + } + return responseBody, nil +} + +type uploadResponse struct { + StatusCode int `json:"status_code"` + StatusMessage string `json:"status_message"` +} + +func buildMultipartPayload(fields map[string]string, mediaDump string, torrentPath string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD write multipart field %q: %w", key, err) + } + } + part, err := writer.CreateFormFile("mediainfo", "upload") + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD create mediainfo form file: %w", err) + } + if _, err := io.WriteString(part, mediaDump); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD write mediainfo form file: %w", err) + } + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD open torrent file: %w", err) + } + defer file.Close() + part, err = writer.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": {`form-data; name="file"; filename="torrent.torrent"`}, + "Content-Type": {"application/x-bittorrent"}, + }) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHD copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: BHD close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveMediaDump(meta api.UploadSubject, dbPath string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + text := readBDInfoNoErr(dbPath, meta) + if text == "" { + return "", errors.New("trackers: BHD missing BDInfo text; generate or attach BDInfo before uploading") + } + return text, nil + case "DVD": + text := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.DVDVOBMediaInfoText), readTextFileNoErr(strings.TrimSpace(meta.MediaInfoTextPath))) + if text == "" { + return "", errors.New("trackers: BHD missing DVD MediaInfo text; generate or attach DVD MediaInfo before uploading") + } + return text, nil + default: + if strings.TrimSpace(meta.MediaInfoTextPath) == "" { + return "", errors.New("trackers: BHD missing mediainfo text; generate or attach MediaInfo before uploading") + } + payload, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + return "", fmt.Errorf("trackers: BHD read mediainfo: %w", err) + } + return string(payload), nil + } +} + +func resolveMediaPath(meta api.UploadSubject, dbPath string) string { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "" + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "" + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "" + } + return paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists)) + default: + return strings.TrimSpace(meta.MediaInfoTextPath) + } +} + +func uploadEndpoint(apiKey string) string { + return strings.TrimRight(bhdBaseURL, "/") + "/api/upload/" + strings.TrimSpace(apiKey) +} + +func extractTorrentID(statusMessage string) string { + matches := bhdTorrentIDPattern.FindStringSubmatch(strings.TrimSpace(statusMessage)) + if len(matches) < 2 { + return "" + } + return strings.TrimSpace(matches[1]) +} + +func writeFailureArtifact(req trackers.PreparationInput, payload []byte, name string) (string, error) { + if strings.TrimSpace(req.Runtime.DBPath) == "" || strings.TrimSpace(req.Meta.SourcePath) == "" { + return "", nil + } + tmpRoot, err := db.Subdir(req.Runtime.DBPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, req.Meta.SourcePath, req.Meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + ext := ".txt" + if bytes.Contains(bytes.ToLower(payload), []byte(" 0 { + return strconv.Itoa(meta.Identity.IMDBID) + } + return "1" +} + +func resolveAnon(cfg config.TrackerConfig) string { + if cfg.Anon { + return "1" + } + return "0" +} + +func resolveLive(cfg config.TrackerConfig) string { + if cfg.DraftDefault || cfg.Draft { + return "0" + } + return "1" +} + +func resolveRegion(region string) string { + allowed := map[string]struct{}{ + "AUS": {}, + "CAN": {}, + "CEE": {}, + "CHN": {}, + "ESP": {}, + "EUR": {}, + "FRA": {}, + "GBR": {}, + "GER": {}, + "HKG": {}, + "ITA": {}, + "JPN": {}, + "KOR": {}, + "NOR": {}, + "NLD": {}, + "RUS": {}, + "TWN": {}, + "USA": {}, + } + upper := strings.ToUpper(strings.TrimSpace(region)) + if _, ok := allowed[upper]; ok { + return upper + } + return "" +} + +func isSD(meta api.UploadSubject) bool { + return IsSD(meta) +} + +func boolFlag(value bool) string { + if value { + return "1" + } + return "0" +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} diff --git a/internal/trackers/impl/standalone/bhdtv/definition.go b/internal/trackers/impl/standalone/bhdtv/definition.go new file mode 100644 index 00000000..781c4e2d --- /dev/null +++ b/internal/trackers/impl/standalone/bhdtv/definition.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhdtv + +import ( + "context" + "fmt" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + + description := buildDescription(assets) + return trackers.DescriptionResult{ + Group: "bhdtv", + Description: description, + }, nil +} + +type bhdtvDuplicateAdapter struct{} + +func (bhdtvDuplicateAdapter) Search(context.Context, api.DuplicateSubject) dupe.AdapterResult { + return dupe.NotRun(dupe.NotRunManualCheckRequired, "BHDTV duplicate search requires a manual check", nil) +} diff --git a/internal/trackers/impl/standalone/bhdtv/definition_test.go b/internal/trackers/impl/standalone/bhdtv/definition_test.go new file mode 100644 index 00000000..f138e708 --- /dev/null +++ b/internal/trackers/impl/standalone/bhdtv/definition_test.go @@ -0,0 +1,150 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhdtv + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDefinitionBuildUploadDryRunBuildsPayload(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + torrentPath := filepath.Join(tempDir, "input.torrent") + mediaPath := filepath.Join(tempDir, "MEDIAINFO_CLEANPATH.txt") + if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + if err := os.WriteFile(mediaPath, []byte("mediainfo text"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + plan, failure := New().Prepare(context.Background(), trackers.PreparationInput{ + Tracker: "BHDTV", + Intent: trackers.PreparationIntentDryRun, + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tempDir, "Show.S01E01.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaPath, + Type: "WEBDL", + TVPack: false, + ReleaseName: "Show: S01E01 DD+ 1080p WEB-DL H.265", + ReleaseNameNoTag: "Show: S01E01 DD+ 1080p WEB-DL H.265", + Identity: api.ExternalIdentity{Category: "TV", TVmazeID: 321}, + ProviderMetadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDbURL: "https://www.imdb.com/title/tt1234567/"}}, + TrackerQuestionnaireAnswers: map[string]map[string]string{}, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }) + if failure != nil { + t.Fatalf("dry run: %v", failure) + } + entry := plan.DryRun() + + if entry.Tracker != "BHDTV" { + t.Fatalf("unexpected tracker %q", entry.Tracker) + } + if entry.Payload["cat"] != "10" { + t.Fatalf("expected tv episode cat 10, got %q", entry.Payload["cat"]) + } + if entry.Payload["subcat"] != "8" { + t.Fatalf("expected tv web subcat 8, got %q", entry.Payload["subcat"]) + } + if entry.Payload["resolution"] != "3" { + t.Fatalf("expected 1080p resolution 3, got %q", entry.Payload["resolution"]) + } + if entry.Payload["url"] != "https://www.tvmaze.com/shows/321" { + t.Fatalf("unexpected url %q", entry.Payload["url"]) + } + if entry.Payload["name"] != "Show.S01E01.DDP.1080p.WEB-DL.H.265" { + t.Fatalf("unexpected name %q", entry.Payload["name"]) + } +} + +func TestUploadParsesViewAndWritesArtifact(t *testing.T) { + tempDir := t.TempDir() + torrentPath := filepath.Join(tempDir, "input.torrent") + mediaPath := filepath.Join(tempDir, "MEDIAINFO_CLEANPATH.txt") + if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + if err := os.WriteFile(mediaPath, []byte("mediainfo text"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(2 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + if got := r.FormValue("api_key"); got != "token" { + t.Error("unexpected api key") + return + } + if got := r.FormValue("cat"); got != "7" { + t.Errorf("unexpected cat %q", got) + return + } + if got := r.FormValue("subcat"); got != "48" { + t.Errorf("unexpected subcat %q", got) + return + } + if got := r.FormValue("resolution"); got != "4" { + t.Errorf("unexpected resolution %q", got) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"view":"https://www.bit-hdtv.com/details.php?id=99"}}`)) + })) + defer server.Close() + + originalURL := uploadURL + uploadURL = server.URL + defer func() { uploadURL = originalURL }() + + plan, failure := New().Prepare(context.Background(), trackers.PreparationInput{ + Tracker: "BHDTV", + Intent: trackers.PreparationIntentUpload, + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tempDir, "Movie.2160p.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaPath, + Type: "REMUX", + ReleaseName: "Movie 2160p REMUX x265", + Identity: api.ExternalIdentity{Category: "MOVIE"}, + }, + TrackerConfig: config.TrackerConfig{ + APIKey: "token", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tempDir, "ua.db")}, + }), + }) + if failure != nil { + t.Fatalf("prepare upload: %v", failure) + } + summary, err := plan.Submit(context.Background()) + if err != nil { + t.Fatalf("upload: %v", err) + } + if summary.Uploaded != 1 || len(summary.UploadedTorrents) != 1 { + t.Fatalf("unexpected summary %#v", summary) + } + if got := summary.UploadedTorrents[0].TorrentURL; got != "https://www.bit-hdtv.com/details.php?id=99" { + t.Fatalf("unexpected torrent url %q", got) + } + if strings.TrimSpace(summary.UploadedTorrents[0].TorrentPath) != "" { + t.Fatal("expected no personalized torrent path without my_announce_url") + } +} diff --git a/internal/trackers/impl/standalone/bhdtv/profile.go b/internal/trackers/impl/standalone/bhdtv/profile.go new file mode 100644 index 00000000..1702f0fb --- /dev/null +++ b/internal/trackers/impl/standalone/bhdtv/profile.go @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhdtv + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns BHDTV identity, preparation, dupe, and artifact behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "BHDTV", + BaseURL: "https://www.bit-hdtv.com", + DescriptionGroup: "bhdtv", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: func(dupe.Dependencies) dupe.Adapter { return bhdtvDuplicateAdapter{} }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "BIT-HDTV", UseMyAnnounce: true}, + } +} + +// New returns a fresh BHDTV definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/bhdtv/upload.go b/internal/trackers/impl/standalone/bhdtv/upload.go new file mode 100644 index 00000000..9bb41275 --- /dev/null +++ b/internal/trackers/impl/standalone/bhdtv/upload.go @@ -0,0 +1,501 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bhdtv + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + uploadURL = "https://www.bit-hdtv.com/takeupload.php" + sourceFlag = "BIT-HDTV" +) + +type uploadState struct { + torrentPath string + releaseName string + screenBlock string + inlineDescr string + mediaDump string + fields map[string]string + artifactPath string +} + +type uploadResponse struct { + Status string `json:"status"` + Success bool `json:"success"` + Message string `json:"message"` + Data struct { + View string `json:"view"` + } `json:"data"` +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, +) (api.UploadSummary, error) { + response, responseBody, err := sendUpload(ctx, body, contentType) + if err != nil { + return api.UploadSummary{}, err + } + viewURL := strings.TrimSpace(response.Data.View) + if viewURL == "" { + artifactPath, artifactErr := writeFailureArtifact(req, responseBody, "upload_failure") + if artifactErr != nil && req.Logger != nil { + req.Logger.Warnf("trackers: BHDTV failure artifact write failed: %v", artifactErr) + } + message := metautil.FirstNonEmptyTrimmed( + commonhttp.ExtractHTTPErrorDetail(responseBody), + commonhttp.RedactErrorDetail(response.Message), + commonhttp.RedactErrorDetail(response.Status), + "upload response did not include a view URL", + ) + if artifactPath != "" { + message += " (" + artifactPath + ")" + } + return api.UploadSummary{}, fmt.Errorf("trackers: BHDTV %s", message) + } + + if strings.TrimSpace(req.TrackerConfig.MyAnnounceURL) != "" { + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "BHDTV") + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, req.TrackerConfig.MyAnnounceURL, viewURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + state.artifactPath = artifactPath + } + + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "BHDTV", + TorrentURL: viewURL, + DownloadURL: viewURL, + TorrentPath: state.artifactPath, + }}, + }, nil +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "BHDTV", + ReleaseName: state.releaseName, + DescriptionGroup: "bhdtv", + Description: state.screenBlock, + Endpoint: uploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + select { + case <-ctx.Done(): + return uploadState{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: BHDTV missing api_key") + } + + descriptionAssets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + descriptionAssets = trackers.DescriptionAssets{} + } + + screenBlock := buildDescription(descriptionAssets) + + mediaDump, err := resolveMediaDump(req.Meta) + if err != nil { + return uploadState{}, err + } + + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + + fields := map[string]string{ + "api_key": strings.TrimSpace(req.TrackerConfig.APIKey), + "name": resolveUploadName(req.Meta), + "mediainfo": mediaDump, + "cat": resolveCategoryID(req.Meta), + "subcat": resolveSubcategoryID(req.Meta), + "resolution": resolveResolutionID(req.Meta), + "sdescr": " ", + "descr": resolveInlineDescription(req.Meta), + "screen": screenBlock, + "url": resolveReferenceURL(req.Meta), + "format": "json", + } + + return uploadState{ + torrentPath: torrentPath, + releaseName: fields["name"], + screenBlock: screenBlock, + inlineDescr: fields["descr"], + mediaDump: mediaDump, + fields: fields, + }, nil +} + +func sendUpload(ctx context.Context, body []byte, contentType string) (uploadResponse, []byte, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHDTV request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHDTV upload request: %w", err) + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return uploadResponse{}, nil, fmt.Errorf("trackers: BHDTV read response: %w", err) + } + + var decoded uploadResponse + if len(responseBody) > 0 { + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return uploadResponse{}, responseBody, fmt.Errorf("trackers: BHDTV decode response: %w", err) + } + } + return decoded, responseBody, nil +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHDTV write multipart field %q: %w", key, err) + } + } + + file, err := os.Open(strings.TrimSpace(torrentPath)) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHDTV open torrent file: %w", err) + } + defer file.Close() + + part, err := writer.CreateFormFile("file", filepath.Base(torrentPath)) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHDTV create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: BHDTV copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: BHDTV close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveMediaDump(meta api.UploadSubject) (string, error) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + text := readBDInfoNoErr("", meta) + if text == "" { + return "", errors.New("trackers: BHDTV missing BD summary") + } + return text, nil + } + + text := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)) + if strings.EqualFold(text, strings.TrimSpace(meta.MediaInfoTextPath)) { + payload, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + return "", fmt.Errorf("trackers: BHDTV read mediainfo: %w", err) + } + return string(payload), nil + } + if strings.TrimSpace(text) != "" { + return text, nil + } + return "", errors.New("trackers: BHDTV missing mediainfo text") +} + +func resolveInlineDescription(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "Disc so Check Mediainfo dump " + } + text, err := resolveMediaDump(meta) + if err != nil { + return "" + } + return text +} + +func resolveUploadName(meta api.UploadSubject) string { + name := metautil.FirstNonEmptyTrimmed( + strings.TrimSpace(meta.ReleaseName), + strings.TrimSpace(meta.ReleaseNameNoTag), + strings.TrimSpace(meta.Filename), + pathutil.Base(meta.SourcePath), + ) + replacer := strings.NewReplacer(" ", ".", ":.", ".", ":", ".", "DD+", "DDP") + normalized := replacer.Replace(name) + for strings.Contains(normalized, "..") { + normalized = strings.ReplaceAll(normalized, "..", ".") + } + return strings.Trim(normalized, ".") +} + +func resolveCategoryID(meta api.UploadSubject) string { + if categoryOf(meta) == "TV" { + if meta.TVPack { + return "12" + } + return "10" + } + return "7" +} + +func resolveSubcategoryID(meta api.UploadSubject) string { + if categoryOf(meta) != "TV" { + return resolveMovieSubcategory(meta) + } + if meta.TVPack { + return resolveTVPackSubcategory(meta.Type) + } + return resolveTVEpisodeSubcategory(meta.Type) +} + +func resolveMovieSubcategory(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "DISC": + if meta.Is3D != "" { + return "46" + } + return "2" + case "REMUX": + switch { + case strings.Contains(strings.ToUpper(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.ReleaseNameNoTag)), "265"): + return "48" + case meta.Is3D != "": + return "45" + default: + return "2" + } + case "HDTV": + return "6" + case "ENCODE": + switch { + case strings.Contains(strings.ToUpper(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.ReleaseNameNoTag)), "265"): + return "43" + case meta.Is3D != "": + return "44" + default: + return "1" + } + case "WEBDL", "WEBRIP": + return "5" + default: + return "0" + } +} + +func resolveTVEpisodeSubcategory(typeValue string) string { + switch strings.ToUpper(strings.TrimSpace(typeValue)) { + case "HDTV": + return "7" + case "WEBDL", "WEBRIP": + return "8" + case "ENCODE": + return "10" + case "REMUX": + return "11" + case "DISC": + return "12" + default: + return "0" + } +} + +func resolveTVPackSubcategory(typeValue string) string { + switch strings.ToUpper(strings.TrimSpace(typeValue)) { + case "HDTV": + return "13" + case "WEBDL": + return "14" + case "WEBRIP": + return "8" + case "ENCODE": + return "16" + case "REMUX": + return "17" + case "DISC": + return "18" + default: + return "0" + } +} + +func resolveResolutionID(meta api.UploadSubject) string { + switch normalizeResolution(metautil.FirstNonEmptyTrimmed(meta.Release.Resolution, meta.ReleaseName, meta.Filename)) { + case "2160P": + return "4" + case "1080P": + return "3" + case "1080I": + return "2" + case "720P": + return "1" + default: + return "10" + } +} + +func resolveReferenceURL(meta api.UploadSubject) string { + if categoryOf(meta) == "TV" && meta.Identity.TVmazeID != 0 { + return fmt.Sprintf("https://www.tvmaze.com/shows/%d", meta.Identity.TVmazeID) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + return "" +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func buildDescription(assets trackers.DescriptionAssets) string { + base := strings.ReplaceAll(strings.TrimSpace(assets.Description), "[img=250]", "[img=250x250]") + parts := make([]string, 0, 1+len(assets.Screenshots)) + if base != "" { + parts = append(parts, base) + } + for _, image := range assets.Screenshots { + webURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.WebURL, image.RawURL)) + imgURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL, image.WebURL)) + if webURL == "" || imgURL == "" { + continue + } + parts = append(parts, fmt.Sprintf("[url=%s][img]%s[/img][/url]", webURL, imgURL)) + } + return strings.Join(parts, " ") +} + +func writeFailureArtifact(req trackers.PreparationInput, payload []byte, name string) (string, error) { + if strings.TrimSpace(req.Runtime.DBPath) == "" || strings.TrimSpace(req.Meta.SourcePath) == "" { + return "", nil + } + tmpRoot, err := db.Subdir(req.Runtime.DBPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, req.Meta.SourcePath, req.Meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + ext := ".txt" + if bytes.Contains(bytes.ToLower(payload), []byte("= 2 { + return match[1] + } + return "" +} + +type responseInfo struct{ StatusCode int } + +func (r responseInfo) ok() bool { + return r.StatusCode >= http.StatusOK && r.StatusCode < http.StatusMultipleChoices +} +func imdbForLookup(meta api.DuplicateSubject) string { + if meta.Identity.IMDBID == 0 { + return "" + } + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) +} +func trackerBaseURL(_ config.Config, _ string, fallback string) string { + return strings.TrimRight(fallback, "/") +} +func trackerHost(baseURL, fallback string) string { + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Hostname() != "" { + return parsed.Hostname() + } + return fallback +} +func loadTrackerCookies(ctx context.Context, cfg config.Config, tracker, domain string) ([]*http.Cookie, error) { + loaded, err := cookies.LoadTrackerHTTPCookies(ctx, cfg.MainSettings.DBPath, tracker, domain) + if err != nil { + return nil, fmt.Errorf("bjs: load tracker cookies: %w", err) + } + return loaded, nil +} +func doHTMLGet(ctx context.Context, client *http.Client, endpoint string, params url.Values, trackerCookies []*http.Cookie) (responseInfo, *xhtml.Node, error) { + status, root, err := commonhttp.GetHTML(ctx, client, endpoint, params, trackerCookies) + if err != nil { + return responseInfo{StatusCode: status}, nil, fmt.Errorf("bjs: HTML request: %w", err) + } + return responseInfo{StatusCode: status}, root, nil +} +func firstNode(root *xhtml.Node, match func(*xhtml.Node) bool) *xhtml.Node { + return commonhttp.FirstNode(root, match) +} +func findNodes(root *xhtml.Node, match func(*xhtml.Node) bool) []*xhtml.Node { + return commonhttp.FindNodes(root, match) +} +func hasClass(node *xhtml.Node, name string) bool { return commonhttp.HasClass(node, name) } +func attrValueHTML(node *xhtml.Node, key string) string { return commonhttp.Attr(node, key) } +func nodeTextHTML(node *xhtml.Node) string { return commonhttp.NodeText(node) } +func addSize(entry *api.DupeEntry, value string) { + trimmed := strings.TrimSpace(value) + entry.SizeText = trimmed + if size, ok := commonhttp.ParseSizeBytes(trimmed); ok { + entry.SizeKnown, entry.SizeBytes = true, size + } +} diff --git a/internal/trackers/impl/standalone/bjs/dupe_test.go b/internal/trackers/impl/standalone/bjs/dupe_test.go new file mode 100644 index 00000000..9dfad602 --- /dev/null +++ b/internal/trackers/impl/standalone/bjs/dupe_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bjs + +import ( + "strings" + "testing" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestExtractBJSResultsNewTVLayout(t *testing.T) { + t.Parallel() + + root := mustParseHTML(t, ` +`) + + entries := extractBJSResults("https://bj-share.info", root, api.DuplicateSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + EpisodeInt: 2, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }) + + if len(entries) != 1 { + t.Fatalf("expected one matching episode entry, got %#v", entries) + } + if entries[0].ID != "123" || entries[0].Name != "Example.S01E02.1080p-GRP" { + t.Fatalf("unexpected entry: %#v", entries[0]) + } + if !entries[0].SizeKnown || entries[0].SizeBytes == 0 { + t.Fatalf("expected parsed size, got %#v", entries[0]) + } +} + +func TestExtractBJSResultsLegacyLoadIfNeededLayout(t *testing.T) { + t.Parallel() + + root := mustParseHTML(t, ` +`) + + entries := extractBJSResults("https://bj-share.info", root, api.DuplicateSubject{}) + if len(entries) != 1 { + t.Fatalf("expected one legacy entry, got %#v", entries) + } + if entries[0].ID != "555" || entries[0].Link != "https://bj-share.info/torrents.php?torrentid=555" { + t.Fatalf("unexpected legacy entry: %#v", entries[0]) + } +} + +func mustParseHTML(t *testing.T, value string) *xhtml.Node { + t.Helper() + + root, err := xhtml.Parse(strings.NewReader(value)) + if err != nil { + t.Fatalf("parse html: %v", err) + } + return root +} diff --git a/internal/trackers/impl/standalone/bjs/profile.go b/internal/trackers/impl/standalone/bjs/profile.go new file mode 100644 index 00000000..9afdf33e --- /dev/null +++ b/internal/trackers/impl/standalone/bjs/profile.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bjs + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns BJS identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "BJS", + BaseURL: baseURL, + DescriptionGroup: "bjs", + UploadContentMode: trackers.UploadContentModeDescription, + LocalizedMetadataLocale: "pt-BR", + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + AudioPolicy: &trackers.AudioPolicy{AllowBloat: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"tracker.bj-share.info"}}, + AuthCapability: standalone.CookieAuthCapability("BJS"), + } +} + +// New returns a fresh BJS definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/bjs/upload.go b/internal/trackers/impl/standalone/bjs/upload.go new file mode 100644 index 00000000..33f93ebc --- /dev/null +++ b/internal/trackers/impl/standalone/bjs/upload.go @@ -0,0 +1,1156 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bjs + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "math" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/autobrr/upbrr/internal/cookies" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +const ( + baseURL = "https://bj-share.info" + uploadURL = baseURL + "/upload.php" + torrentURL = baseURL + "/torrents.php?torrentid=" + sourceFlag = "BJ" +) + +var ( + authPattern = regexp.MustCompile(`name="auth"\s+value="([^"]+)"`) + idPattern = regexp.MustCompile(`action=download&id=(\d+)|torrentid=(\d+)`) + mediaInfoDurationLinePattern = regexp.MustCompile(`(?im)^\s*duration(?:\s*/\s*string[123]?)?\s*:\s*(.+)$`) + mediaInfoDurationTokenPattern = regexp.MustCompile( + `(?i)(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|hours?|hrs?|hr|h|minutes?|mins?|min|mn|m|seconds?|secs?|sec|s)\b`, + ) + isoDurationPattern = regexp.MustCompile(`(?i)^pt(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$`) +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + blockedReason string + questionnaire *api.TrackerQuestionnaire +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: BJS %s", state.blockedReason) + } + files := []commonhttp.FileField{{ + FieldName: "file_input", + FileName: filepath.Base(state.torrentPath), + Path: state.torrentPath, + }} + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "BJS") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BJS request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + httpReq.Header.Set("Referer", uploadURL) + commonhttp.ApplyCookies(httpReq, cookies) + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BJS upload request: %w", err) + } + defer resp.Body.Close() + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode >= 200 && resp.StatusCode < 400, + commonhttp.DefaultResponsePreviewBytes, + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BJS read upload response: %w", err) + } + match := idPattern.FindStringSubmatch(finalURL + "\n" + string(responseBody)) + id := metautil.FirstNonEmptyTrimmed(matchValue(match, 1), matchValue(match, 2)) + if resp.StatusCode >= 200 && resp.StatusCode < 400 && id != "" { + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "BJS", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}, + }, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "BJS", "upload_failure", responsePreview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("BJS", resp.StatusCode, responsePreview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + fields := maps.Clone(state.fields) + if _, ok := fields["auth"]; ok { + fields["auth"] = "[redacted]" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "BJS", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "bjs", + Description: state.description, + Endpoint: uploadURL, + Payload: fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + cookies, err := loadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, err + } + auth := "dry-run-auth" + if !dryRun { + auth, err = fetchAuth(ctx, cookies) + if err != nil { + return uploadState{}, nil, err + } + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + fields := buildFields(req.Meta, description, auth, standalone.QuestionnaireAnswers(req.Meta, "BJS")) + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), + fields: fields, + questionnaire: buildQuestionnaire(req.Meta, fields), + } + if reason := validateFields(fields); reason != "" { + state.blockedReason = reason + } + return state, cookies, nil +} + +func buildFields(meta api.UploadSubject, description string, auth string, answers map[string]string) map[string]string { + width, height := resolveResolution(meta) + runtimeMinutes := resolveRuntime(meta) + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + + var tmdbOriginalTitle, tmdbTitle string + if meta.ProviderMetadata.TMDB != nil { + tmdbOriginalTitle = meta.ProviderMetadata.TMDB.OriginalTitle + tmdbTitle = meta.ProviderMetadata.TMDB.Title + } + + fields := map[string]string{ + "audio": resolveAudio(meta), + "auth": auth, + "codecaudio": resolveAudioCodec(meta), + "codecvideo": resolveVideoCodec(meta), + "duracaoHR": strconv.Itoa(runtimeMinutes / 60), + "duracaoMIN": strconv.Itoa(runtimeMinutes % 60), + "duracaotipo": "selectbox", + "fichatecnica": description, + "formato": resolveContainer(meta), + "idioma": resolveLanguage(meta), + "imdblink": resolveIDLink(meta), + "qualidade": resolveQuality(meta), + "release": strings.TrimSpace(meta.ServiceLongName), + "remaster_title": resolveRemasterTitle(meta), + "resolucaoh": height, + "resolucaow": width, + "sinopse": metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["overview"]), resolveOverview(meta, ptBR)), + "submit": "true", + "tags": metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["tags"]), resolveTags(meta, ptBR)), + "tipolegenda": resolveSubtitle(meta), + "title": metautil.FirstNonEmptyTrimmed(tmdbOriginalTitle, meta.Release.Title), + "titulobrasileiro": metautil.FirstNonEmptyTrimmed(ptBR.Title, tmdbTitle, meta.Release.Title), + "traileryoutube": resolveYouTube(meta, ptBR), + "type": resolveType(meta), + "year": resolveYearLabel(meta), + } + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + if category == "MOVIE" { + fields["adulto"] = resolveAdult(meta) + fields["diretor"] = resolveDirectors(meta) + fields["datalancamento"] = resolveReleaseDate(meta) + } + if category == "TV" { + fields["diretor"] = resolveCreators(meta) + if meta.TVPack { + fields["tipo"] = "season" + } else { + fields["tipo"] = "episode" + } + fields["season"] = strconv.Itoa(meta.SeasonInt) + fields["episode"] = strconv.Itoa(meta.EpisodeInt) + fields["network"] = resolveNetworks(meta) + + numSeasons := 0 + if meta.ProviderMetadata.IMDB != nil { + numSeasons = len(meta.ProviderMetadata.IMDB.SeasonsSummary) + } + fields["numtemporadas"] = strconv.Itoa(numSeasons) + + var originCountry []string + if meta.ProviderMetadata.TMDB != nil { + for _, code := range meta.ProviderMetadata.TMDB.OriginCountry { + originCountry = append(originCountry, metautil.ISO3166PortugueseName(code, code)) + } + } + fields["pais"] = strings.Join(originCountry, ", ") + + fields["diretorserie"] = resolveDirectors(meta) + fields["avaliacao"] = resolveIMDbRating(meta) + fields["datalancamento"] = resolveReleaseDate(meta) + } + if !meta.Anime { + fields["validimdb"] = "yes" + fields["imdbrating"] = resolveIMDbRating(meta) + fields["elenco"] = resolveCast(meta) + } + if meta.Anime && category == "MOVIE" { + fields["tipo"] = "movie" + } + if meta.Anime && category == "TV" { + fields["adulto"] = resolveAdult(meta) + } + if strings.TrimSpace(meta.Repack) != "" { + fields["repack"] = "on" + } + if resolvePoster(meta) != "" { + fields["image"] = resolvePoster(meta) + } + screens := resolveScreens(meta) + if len(screens) > 0 { + fields["screenshots[]"] = strings.Join(screens, ",") + } + return fields +} + +func buildQuestionnaire(meta api.UploadSubject, fields map[string]string) *api.TrackerQuestionnaire { + current := standalone.QuestionnaireAnswers(meta, "BJS") + var items []api.TrackerQuestionnaireField + if strings.TrimSpace(fields["sinopse"]) == "" { + items = append(items, api.TrackerQuestionnaireField{ + Key: "overview", + Label: "Overview", + Kind: "textarea", + Value: current["overview"], + Required: true, + }) + } + if strings.TrimSpace(fields["tags"]) == "" { + items = append(items, api.TrackerQuestionnaireField{ + Key: "tags", + Label: "Tags", + Kind: "text", + Value: current["tags"], + Required: true, + }) + } + if len(items) == 0 { + return nil + } + return &api.TrackerQuestionnaire{Tracker: "BJS", Fields: items} +} + +func validateFields(fields map[string]string) string { + if strings.TrimSpace(fields["imdblink"]) == "" { + return "missing IMDb or TMDb identifier" + } + if strings.TrimSpace(fields["sinopse"]) == "" { + return "missing overview" + } + if strings.TrimSpace(fields["diretor"]) == "" || strings.EqualFold(strings.TrimSpace(fields["diretor"]), "skipped") { + return "missing director/creator credits" + } + return "" +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + meta := req.Meta + var parts []string + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if logo := resolveLogo(meta); logo != "" { + parts = append(parts, "[align=center][img]"+logo+"[/img][/align]") + } + + // TV Episode details + epTitle := meta.EpisodeTitle + epOverview := meta.EpisodeOverview + if meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Localized != nil { + if ptBR, ok := meta.ProviderMetadata.TMDB.Localized["pt-BR"]; ok { + if ptBR.EpisodeTitle != "" { + epTitle = ptBR.EpisodeTitle + } + if ptBR.EpisodeOverview != "" { + epOverview = ptBR.EpisodeOverview + } + } + } + epTitle = strings.TrimSpace(epTitle) + epOverview = strings.TrimSpace(epOverview) + if epOverview != "" { + if epTitle != "" { + parts = append(parts, "[align=center]"+epTitle+"[/align]") + } + parts = append(parts, "[align=center]"+epOverview+"[/align]") + } + + // File information + discType := strings.ToUpper(strings.TrimSpace(meta.DiscType)) + if discType == "DVD" || discType == "HDDVD" { + mediainfo := strings.TrimSpace(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath)) + if mediainfo != "" { + parts = append(parts, "[hide=DVD MediaInfo][pre]"+mediainfo+"[/pre][/hide]") + } + } + if discType == "BDMV" { + bdinfo, _ := trackers.ReadBDInfo(req.Runtime.DBPath, meta) + parts = append(parts, "[hide=BDInfo][pre]"+bdinfo+"[/pre][/hide]") + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // Signature + link, _ := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[align=center][url=%s]Upload realizado via %s[/url][/align]", link, "upbrr")) + + // Join and finalize + description := strings.Join(parts, "\n\n") + finalized := finalizeDescription(description) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "BJS", req.Runtime.DBPath, finalized, req.Logger) + } + + return finalized +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "BJS", "bj-share.info") + if err != nil { + return values, fmt.Errorf("trackers: BJS load cookies: %w", err) + } + return values, nil +} + +func fetchAuth(ctx context.Context, cookies []*http.Cookie) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uploadURL, nil) + if err != nil { + return "", fmt.Errorf("trackers: BJS auth token request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(req, cookies) + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) + if err != nil { + return "", fmt.Errorf("trackers: BJS auth token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + match := authPattern.FindStringSubmatch(string(body)) + if len(match) < 2 { + return "", errors.New("trackers: BJS auth token not found") + } + return strings.TrimSpace(match[1]), nil +} + +func resolveType(meta api.UploadSubject) string { + if meta.Anime { + return "13" + } + if strings.EqualFold(categoryOf(meta), "TV") { + return "1" + } + return "0" +} + +func resolveContainer(meta api.UploadSubject) string { + container := strings.ToLower(strings.TrimSpace(meta.Container)) + switch container { + case "mkv", "mp4", "avi", "vob", "m2ts", "ts": + return strings.ToUpper(container) + default: + return "Outro" + } +} + +func resolveAudio(meta api.UploadSubject) string { + for _, lang := range meta.AudioLanguages { + lower := strings.ToLower(strings.TrimSpace(lang)) + if lower == "portuguese" || lower == "português" || lower == "pt" { + if len(meta.AudioLanguages) > 1 { + return "Dual Áudio" + } + return "Dublado" + } + } + return "Legendado" +} + +func resolveLanguage(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "Outro" + } + + langCode := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage)) + if langCode == "" { + return "Outro" + } + + if langCode == "pt" { + for _, country := range meta.ProviderMetadata.TMDB.OriginCountry { + if strings.ToUpper(strings.TrimSpace(country)) == "PT" { + return "Português (pt)" + } + } + return "Português" + } + + return metautil.ISO639PortugueseName(langCode, "Outro") +} + +func resolveSubtitle(meta api.UploadSubject) string { + for _, lang := range meta.SubtitleLanguages { + lower := strings.ToLower(strings.TrimSpace(lang)) + if lower == "portuguese" || lower == "português" || lower == "pt" { + return "Embutida" + } + } + return "Nenhuma" +} + +func resolveResolution(meta api.UploadSubject) (string, string) { + height := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(meta.Release.Resolution), "p"), "i") + switch height { + case "2160": + return "3840", "2160" + case "1080": + return "1920", "1080" + case "720": + return "1280", "720" + default: + return "0", "0" + } +} + +func resolveVideoCodec(meta api.UploadSubject) string { + value := strings.ToLower(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.VideoEncode, meta.VideoCodec))) + switch { + case strings.Contains(value, "265"), strings.Contains(value, "hevc"): + return "H.265" + case strings.Contains(value, "264"), strings.Contains(value, "avc"): + return "H.264" + case strings.Contains(value, "av1"): + return "AV1" + case strings.Contains(value, "vp9"): + return "VP9" + case strings.Contains(value, "xvid"): + return "XviD" + default: + return metautil.FirstNonEmptyTrimmed(meta.VideoCodec, "Outro") + } +} + +func resolveAudioCodec(meta api.UploadSubject) string { + audio := strings.ToUpper(strings.TrimSpace(meta.Audio)) + switch { + case strings.Contains(audio, "DTS:X"): + return "DTS-X" + case strings.Contains(audio, "ATMOS"): + return "E-AC-3 JOC" + case strings.Contains(audio, "TRUEHD"): + return "TrueHD" + case strings.Contains(audio, "DTS-HD"): + return "DTS-HD" + case strings.Contains(audio, "FLAC"): + return "FLAC" + case strings.Contains(audio, "LPCM"), strings.Contains(audio, "PCM"): + return "PCM" + case strings.Contains(audio, "DTS"): + return "DTS" + case strings.Contains(audio, "DD+"), strings.Contains(audio, "E-AC-3"): + return "E-AC-3" + case strings.Contains(audio, "DD"), strings.Contains(audio, "AC3"): + return "AC3" + case strings.Contains(audio, "AAC"): + return "AAC" + default: + return "Outro" + } +} + +func resolveQuality(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "DISC": + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + if meta.SourceSize > 66<<30 { + return "BD100" + } + if meta.SourceSize > 50<<30 { + return "BD66" + } + if meta.SourceSize > 25<<30 { + return "BD50" + } + return "BD25" + } + return "DVD9" + case "REMUX": + return "Remux" + case "WEBDL": + return "WEB-DL" + case "WEBRIP": + return "WEBRip" + case "HDTV": + return "HDTV" + default: + return "Outro" + } +} + +func resolveRuntime(meta api.UploadSubject) int { + for _, candidate := range []string{ + commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), + strings.TrimSpace(meta.DVDVOBMediaInfoText), + } { + if minutes := parseMediaInfoDurationMinutes(candidate); minutes > 0 { + return minutes + } + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + if meta.Disc.DurationSeconds > 0 { + return int(math.Round(meta.Disc.DurationSeconds / 60)) + } + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.RuntimeMinutes > 0 { + return meta.ProviderMetadata.IMDB.RuntimeMinutes + } + if meta.ProviderMetadata.TMDB != nil { + return meta.ProviderMetadata.TMDB.Runtime + } + if meta.ProviderMetadata.TVmaze != nil { + return meta.ProviderMetadata.TVmaze.Runtime + } + return 0 +} + +// parseMediaInfoDurationMinutes returns rounded minutes from the first parseable +// MediaInfo Duration or Duration/String[1-3] line, including ISO-8601-like values. +func parseMediaInfoDurationMinutes(text string) int { + for _, matches := range mediaInfoDurationLinePattern.FindAllStringSubmatch(text, -1) { + if len(matches) != 2 { + continue + } + if minutes := parseMediaInfoDurationValueMinutes(matches[1]); minutes > 0 { + return minutes + } + } + return 0 +} + +func parseMediaInfoDurationValueMinutes(value string) int { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0 + } + if matches := isoDurationPattern.FindStringSubmatch(trimmed); len(matches) == 4 { + return mediaInfoDurationSecondsToMinutes(durationComponentSeconds(matches[1], matches[2], matches[3], "")) + } + if strings.Contains(trimmed, ":") { + return mediaInfoDurationSecondsToMinutes(parseMediaInfoDurationColonSeconds(trimmed)) + } + if seconds := parseMediaInfoDurationTokenSeconds(trimmed); seconds > 0 { + return mediaInfoDurationSecondsToMinutes(seconds) + } + if fields := strings.Fields(trimmed); len(fields) > 0 { + if ms, err := strconv.ParseFloat(strings.ReplaceAll(fields[0], ",", ""), 64); err == nil && ms > 10000 { + return int(math.Round(ms / 60000.0)) + } + } + return 0 +} + +func parseMediaInfoDurationTokenSeconds(value string) float64 { + var total float64 + for _, matches := range mediaInfoDurationTokenPattern.FindAllStringSubmatch(value, -1) { + if len(matches) != 3 { + continue + } + amount, err := strconv.ParseFloat(strings.ReplaceAll(matches[1], ",", ""), 64) + if err != nil || amount <= 0 { + continue + } + switch strings.ToLower(matches[2]) { + case "h", "hr", "hrs", "hour", "hours": + total += amount * 3600 + case "m", "mn", "min", "mins", "minute", "minutes": + total += amount * 60 + case "s", "sec", "secs", "second", "seconds": + total += amount + case "ms", "msec", "msecs", "millisecond", "milliseconds": + total += amount / 1000 + } + } + return total +} + +func parseMediaInfoDurationColonSeconds(value string) float64 { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) < 2 { + return 0 + } + var total float64 + multiplier := 1.0 + for i := len(parts) - 1; i >= 0; i-- { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + amount, err := strconv.ParseFloat(strings.ReplaceAll(part, ",", ""), 64) + if err != nil || amount < 0 { + return 0 + } + total += amount * multiplier + multiplier *= 60 + } + return total +} + +func durationComponentSeconds(hours string, minutes string, seconds string, milliseconds string) float64 { + totalSeconds := parseDurationComponent(hours) * 3600 + totalSeconds += parseDurationComponent(minutes) * 60 + totalSeconds += parseDurationComponent(seconds) + totalSeconds += parseDurationComponent(milliseconds) / 1000 + return totalSeconds +} + +func mediaInfoDurationSecondsToMinutes(seconds float64) int { + if seconds <= 0 { + return 0 + } + return int(math.Round(seconds / 60.0)) +} + +func parseDurationComponent(value string) float64 { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0 + } + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func resolveIDLink(meta api.UploadSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + if meta.Identity.TMDBID > 0 { + if strings.EqualFold(categoryOf(meta), "TV") { + return fmt.Sprintf("tv/%d", meta.Identity.TMDBID) + } + return fmt.Sprintf("movie/%d", meta.Identity.TMDBID) + } + return "" +} + +// resolveOverview prefers scoped TV synopsis for episode/season-pack uploads, +// then localized title-level overview, then TMDB or IMDB fallback text. +func resolveOverview(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + if shouldUseScopedTVOverview(meta) && ptBR.EpisodeOverview != "" { + return strings.TrimSpace(ptBR.EpisodeOverview) + } + if ptBR.Overview != "" { + return strings.TrimSpace(ptBR.Overview) + } + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + } + return "" +} + +// shouldUseScopedTVOverview reports whether BJS should prefer season or +// episode localized overview over title-level synopsis text. +func shouldUseScopedTVOverview(meta api.UploadSubject) bool { + if meta.SeasonInt <= 0 { + return false + } + if !isTVUpload(meta) { + return false + } + if meta.TVPack { + return true + } + return meta.EpisodeInt > 0 +} + +// isTVUpload reports whether canonical identity classifies the upload as TV. +func isTVUpload(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +// resolveTags returns BJS tag text from localized genres or translated fallback +// genres, preserving unknown fallback genre names after tag normalization. +func resolveTags(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + // 1. Use localized if available + if ptBR.Genres != "" { + genres := strings.Split(strings.TrimSpace(ptBR.Genres), ",") + out := make([]string, 0, len(genres)) + for _, g := range genres { + g = strings.TrimSpace(g) + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + g, _, _ = transform.String(t, g) + g = strings.ReplaceAll(g, " ", ".") + g = strings.ToLower(g) + if g != "" { + out = append(out, g) + } + } + return strings.Join(out, ", ") + } + + // 2. Use metautil.TranslateGenreToPortugueseStrict to translate + var genreText string + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + default: + genreText = strings.TrimSpace(meta.Release.Genre) + } + + if genreText == "" { + return "" + } + + genres := strings.Split(genreText, ",") + out := make([]string, 0, len(genres)) + for _, g := range genres { + g = strings.TrimSpace(g) + if g == "" { + continue + } + translated := metautil.TranslateGenreToPortugueseStrict(g) + if translated == "" { + translated = g + } + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + tag, _, _ := transform.String(t, translated) + tag = strings.ReplaceAll(strings.TrimSpace(tag), " ", ".") + if tag != "" { + out = append(out, strings.ToLower(tag)) + } + } + return strings.Join(out, ", ") +} + +func resolveYouTube(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + if ptBR.TrailerURL != "" { + return strings.TrimSpace(ptBR.TrailerURL) + } + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + return "" +} + +func resolveNetworks(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Networks) > 0 { + names := make([]string, 0, len(meta.ProviderMetadata.TMDB.Networks)) + for _, n := range meta.ProviderMetadata.TMDB.Networks { + if strings.TrimSpace(n.Name) != "" { + names = append(names, strings.TrimSpace(n.Name)) + } + } + return strings.Join(names, ", ") + } + return "" +} + +func resolveReleaseDate(meta api.UploadSubject) string { + rawDate := "" + if meta.ProviderMetadata.TMDB != nil { + rawDate = metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.ReleaseDate, meta.ProviderMetadata.TMDB.FirstAirDate) + } + if rawDate == "" { + return "" + } + t, err := time.Parse("2006-01-02", rawDate) + if err == nil { + return t.Format("02 Jan 2006") + } + return "" +} + +func resolveYearLabel(meta api.UploadSubject) string { + year := resolveYear(meta) + if strings.EqualFold(categoryOf(meta), "TV") { + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.EndYear > 0 { + return fmt.Sprintf("%d-%d", year, meta.ProviderMetadata.IMDB.EndYear) + } + return fmt.Sprintf("%d-", year) + } + return strconv.Itoa(year) +} + +func resolveRemasterTitle(meta api.UploadSubject) string { + var tags []string + + edition := strings.TrimSpace(meta.Edition) + editionLower := strings.ToLower(edition) + editionEntries := []struct{ keyword, label string }{ + {"director's cut", "Director's Cut"}, + {"extended", "Extended Edition"}, + {"imax", "IMAX"}, + {"open matte", "Open Matte"}, + {"noir", "Noir Edition"}, + {"theatrical", "Theatrical Cut"}, + {"uncut", "Uncut"}, + {"unrated", "Unrated"}, + {"uncensored", "Uncensored"}, + } + for _, entry := range editionEntries { + if strings.Contains(editionLower, entry.keyword) { + tags = append(tags, entry.label) + break + } + } + + audio := strings.ToUpper(strings.TrimSpace(meta.Audio)) + if strings.Contains(audio, "ATMOS") { + tags = append(tags, "Dolby Atmos") + } + + if meta.BitDepth == "10" { + tags = append(tags, "10-bit") + } + + hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) + if strings.Contains(hdr, "DV") || strings.Contains(hdr, "DOLBY VISION") { + tags = append(tags, "Dolby Vision") + } + if strings.Contains(hdr, "HDR10+") { + tags = append(tags, "HDR10+") + } + if strings.Contains(hdr, "HDR") && !strings.Contains(hdr, "HDR10+") { + tags = append(tags, "HDR10") + } + + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + tags = append(tags, "Remux") + } + + if meta.HasCommentary { + tags = append(tags, "Com comentários") + } + + priority := []string{ + "Dolby Atmos", + "Remux", + "Director's Cut", + "Extended Edition", + "IMAX", + "Open Matte", + "Noir Edition", + "Theatrical Cut", + "Uncut", + "Unrated", + "Uncensored", + "10-bit", + "Dolby Vision", + "HDR10+", + "HDR10", + "Com extras", + "Com comentários", + } + + tagSet := make(map[string]bool) + for _, t := range tags { + tagSet[t] = true + } + + var ordered []string + for _, p := range priority { + if tagSet[p] { + ordered = append(ordered, p) + } + } + + return strings.Join(ordered, " / ") +} + +func resolveYear(meta api.UploadSubject) int { + if meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0 { + return meta.ProviderMetadata.TMDB.Year + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0 { + return meta.ProviderMetadata.IMDB.Year + } + return meta.Release.Year +} + +// resolveAdult returns the BJS adult flag from localized, TMDB, IMDB, and +// release genre text after accent-insensitive keyword matching. +func resolveAdult(meta api.UploadSubject) string { + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + parts := []string{resolveTags(meta, ptBR), ptBR.Genres} + if meta.ProviderMetadata.TMDB != nil { + parts = append(parts, meta.ProviderMetadata.TMDB.Keywords, meta.ProviderMetadata.TMDB.Genres) + } + if meta.ProviderMetadata.IMDB != nil { + parts = append(parts, meta.ProviderMetadata.IMDB.Genres) + } + parts = append(parts, meta.Release.Genre) + genres := normalizeAdultText(strings.Join(parts, " ")) + if meta.Anime && strings.Contains(genres, "hentai") { + return "1" + } + for _, keyword := range []string{"xxx", "erotic", "erotico", "porn", "adult", "adulto", "orgy", "orgia"} { + if strings.Contains(genres, keyword) { + return "1" + } + } + return "2" +} + +// normalizeAdultText folds case and diacritics before adult keyword matching. +func normalizeAdultText(value string) string { + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + normalized, _, _ := transform.String(t, strings.ToLower(value)) + return normalized +} + +func resolveDirectors(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Directors) > 0 { + return firstTrimmed(meta.ProviderMetadata.TMDB.Directors) + } + if meta.ProviderMetadata.IMDB != nil { + names := make([]string, 0, len(meta.ProviderMetadata.IMDB.Directors)) + for _, p := range meta.ProviderMetadata.IMDB.Directors { + if strings.TrimSpace(p.Name) != "" { + names = append(names, strings.TrimSpace(p.Name)) + } + } + if len(names) > 0 { + return names[0] + } + } + return "" +} + +func resolveCreators(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Creators) > 0 { + return firstTrimmed(meta.ProviderMetadata.TMDB.Creators) + } + if meta.ProviderMetadata.IMDB != nil { + names := make([]string, 0, len(meta.ProviderMetadata.IMDB.Creators)) + for _, p := range meta.ProviderMetadata.IMDB.Creators { + if strings.TrimSpace(p.Name) != "" { + names = append(names, strings.TrimSpace(p.Name)) + } + } + if len(names) > 0 { + return names[0] + } + } + return "" +} + +func resolveCast(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Cast) > 0 { + return strings.Join(firstNTrimmed(meta.ProviderMetadata.TMDB.Cast, 5), ", ") + } + if meta.ProviderMetadata.IMDB != nil { + names := make([]string, 0, len(meta.ProviderMetadata.IMDB.Stars)) + for _, p := range meta.ProviderMetadata.IMDB.Stars { + if strings.TrimSpace(p.Name) != "" { + names = append(names, strings.TrimSpace(p.Name)) + } + } + return strings.Join(firstNTrimmed(names, 5), ", ") + } + return "" +} + +func resolveIMDbRating(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Rating > 0 { + return strconv.FormatFloat(meta.ProviderMetadata.IMDB.Rating, 'f', 1, 64) + } + return "" +} + +func resolveLogo(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo) != "" { + return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo), "/") + } + return "" +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + if meta.ProviderMetadata.TMDB.Localized != nil { + if localized, ok := meta.ProviderMetadata.TMDB.Localized["pt-BR"]; ok && strings.TrimSpace(localized.Poster) != "" { + return strings.TrimSpace(localized.Poster) + } + } + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + return "" +} + +func resolveScreens(_ api.UploadSubject) []string { + return nil +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func firstTrimmed(values []string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func firstNTrimmed(values []string, limit int) []string { + if limit <= 0 { + return nil + } + out := make([]string, 0, limit) + seen := make(map[string]struct{}, limit) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + if len(out) == limit { + break + } + } + return out +} + +func matchValue(values []string, idx int) string { + if idx >= 0 && idx < len(values) { + return values[idx] + } + return "" +} diff --git a/internal/trackers/impl/standalone/bjs/upload_test.go b/internal/trackers/impl/standalone/bjs/upload_test.go new file mode 100644 index 00000000..997875b6 --- /dev/null +++ b/internal/trackers/impl/standalone/bjs/upload_test.go @@ -0,0 +1,557 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bjs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveRuntimePrefersMediaInfoDuration(t *testing.T) { + root := t.TempDir() + miPath := filepath.Join(root, "MEDIAINFO.txt") + if err := os.WriteFile(miPath, []byte("General\nDuration : 1 h 31 min\n"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + meta := api.UploadSubject{ + MediaInfoTextPath: miPath, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{RuntimeMinutes: 120}, + }, + } + + if got := resolveRuntime(meta); got != 91 { + t.Fatalf("expected MediaInfo runtime 91 minutes, got %d", got) + } +} + +func TestResolveRuntimeFallsBackToExternalMetadata(t *testing.T) { + meta := api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{RuntimeMinutes: 95}, + }, + } + + if got := resolveRuntime(meta); got != 95 { + t.Fatalf("expected IMDb runtime fallback, got %d", got) + } +} + +func TestResolveRuntimePrefersBDInfoLengthForBDMV(t *testing.T) { + meta := api.UploadSubject{ + DiscType: "BDMV", + Disc: api.DiscFacts{DurationSeconds: 6000}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{RuntimeMinutes: 120}, + }, + } + + if got := resolveRuntime(meta); got != 100 { + t.Fatalf("expected BDInfo runtime 100 minutes, got %d", got) + } +} + +func TestParseMediaInfoDurationMinutes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected int + }{ + { + name: "valid hours and minutes", + content: "duration : 2 h 15 m", + expected: 135, + }, + { + name: "duration string label", + content: "Duration/String : 2 h 5 min 2 s", + expected: 125, + }, + { + name: "duration string1 label", + content: "\tDuRaTiOn/String1 : 2 h 5 min 2 s", + expected: 125, + }, + { + name: "duration spaced slash label", + content: "Duration / String2 : 2 hrs 5 mins 2 secs", + expected: 125, + }, + { + name: "duration string3 colon label", + content: "Duration/String3 : 02:05:02.000", + expected: 125, + }, + { + name: "iso duration value", + content: "Duration/String : PT2H5M2S", + expected: 125, + }, + { + name: "valid milliseconds", + content: "duration : 120000", + expected: 2, + }, + { + name: "empty fields", + content: "duration : ", + expected: 0, + }, + { + name: "invalid string1 skips to later duration", + content: "Duration/String1 : not a duration\nDuration/String2 : 01:30:00.000", + expected: 90, + }, + { + name: "duration string4 remains ignored", + content: "Duration/String4 : 01:30:00.000", + expected: 0, + }, + { + name: "adjacent duration fields remain ignored", + content: "Source_Duration : 01:30:00.000\nDuration_Start : 01:30:00.000", + expected: 0, + }, + { + name: "no duration keyword", + content: "something else", + expected: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseMediaInfoDurationMinutes(tc.content) + if got != tc.expected { + t.Fatalf("expected %d, got %d", tc.expected, got) + } + }) + } +} + +func TestBuildFieldsLimitsDirectorCreatorAndSetsRepack(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Repack: "REPACK", + Identity: api.ExternalIdentity{ + Category: "TV", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Creators: []string{"Creator One", "Creator Two"}, + Directors: []string{"Director One", "Director Two"}, + OriginCountry: []string{"BR", "US"}, + Title: "Title", + }, + IMDB: &api.IMDBMetadata{Year: 2026}, + }, + } + + fields := buildFields(meta, "description", "auth", nil) + if got := fields["diretor"]; got != "Creator One" { + t.Fatalf("expected first creator only, got %q", got) + } + if got := fields["diretorserie"]; got != "Director One" { + t.Fatalf("expected first director only, got %q", got) + } + if got := fields["repack"]; got != "on" { + t.Fatalf("expected repack flag, got %q", got) + } + if got := fields["pais"]; got != "Brasil, Estados Unidos da América" { + t.Fatalf("expected translated countries, got %q", got) + } +} + +func TestBuildFieldsWithNilMetadata(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Title: "Test TV Title", + }, + Identity: api.ExternalIdentity{ + Category: "TV", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: nil, + IMDB: nil, + }, + } + + // Make sure buildFields does not panic and populates fallback title from release + fields := buildFields(meta, "description", "auth", nil) + if got := fields["title"]; got != "Test TV Title" { + t.Fatalf("expected title to fall back to release title, got %q", got) + } + if got := fields["pais"]; got != "" { + t.Fatalf("expected empty country for nil TMDB, got %q", got) + } + if got := fields["numtemporadas"]; got != "0" { + t.Fatalf("expected 0 seasons for nil IMDB, got %q", got) + } + if got := fields["idioma"]; got != "Outro" { + t.Fatalf("expected idioma to fall back to 'Outro' for nil TMDB, got %q", got) + } + if got := fields["network"]; got != "" { + t.Fatalf("expected network to be empty for nil TMDB, got %q", got) + } + if got := fields["avaliacao"]; got != "" { + t.Fatalf("expected avaliacao to be empty for nil IMDB, got %q", got) + } + if got := fields["elenco"]; got != "" { + t.Fatalf("expected elenco to be empty for nil TMDB/IMDB, got %q", got) + } + if got := fields["traileryoutube"]; got != "" { + t.Fatalf("expected traileryoutube to be empty for nil TMDB, got %q", got) + } +} + +func TestBuildFieldsWithTMDBNilAndIMDBPresent(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Title: "Test TV Title", + }, + Identity: api.ExternalIdentity{ + Category: "TV", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: nil, + IMDB: &api.IMDBMetadata{ + Rating: 8.5, + Stars: []api.IMDBPerson{{Name: "Star One"}, {Name: "Star Two"}}, + }, + }, + } + + fields := buildFields(meta, "description", "auth", nil) + if got := fields["avaliacao"]; got != "8.5" { + t.Fatalf("expected avaliacao from IMDB, got %q", got) + } + if got := fields["elenco"]; got != "Star One, Star Two" { + t.Fatalf("expected elenco from IMDB stars, got %q", got) + } + if got := fields["idioma"]; got != "Outro" { + t.Fatalf("expected idioma to fall back to 'Outro' for nil TMDB, got %q", got) + } +} + +func TestBuildFieldsWithTMDBPresentAndIMDBNil(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Title: "Test TV Title", + }, + Identity: api.ExternalIdentity{ + Category: "TV", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + OriginalTitle: "Original Title", + Title: "Title", + OriginalLanguage: "en", + Networks: []api.TMDBNetwork{{Name: "HBO"}}, + Cast: []string{"Cast One", "Cast Two"}, + YouTube: "https://youtube.com/watch?v=123", + }, + IMDB: nil, + }, + } + + fields := buildFields(meta, "description", "auth", nil) + if got := fields["title"]; got != "Original Title" { + t.Fatalf("expected title from TMDB OriginalTitle, got %q", got) + } + if got := fields["idioma"]; got != "Inglês" { + t.Fatalf("expected idioma from TMDB original language, got %q", got) + } + if got := fields["network"]; got != "HBO" { + t.Fatalf("expected network from TMDB, got %q", got) + } + if got := fields["elenco"]; got != "Cast One, Cast Two" { + t.Fatalf("expected elenco from TMDB cast, got %q", got) + } + if got := fields["traileryoutube"]; got != "https://youtube.com/watch?v=123" { + t.Fatalf("expected traileryoutube URL from TMDB, got %q", got) + } + if got := fields["avaliacao"]; got != "" { + t.Fatalf("expected empty avaliacao for nil IMDB, got %q", got) + } +} + +func TestBuildFieldsWithTMDBLocalizedAndIMDBNil(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Title: "Test TV Title", + }, + Identity: api.ExternalIdentity{ + Category: "TV", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + OriginalTitle: "Original Title", + Title: "Title", + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": { + Title: "Título Localizado", + TrailerURL: "https://youtube.com/watch?v=localized", + }, + }, + }, + IMDB: nil, + }, + } + + fields := buildFields(meta, "description", "auth", nil) + if got := fields["titulobrasileiro"]; got != "Título Localizado" { + t.Fatalf("expected titulobrasileiro from TMDB localized title, got %q", got) + } + if got := fields["traileryoutube"]; got != "https://youtube.com/watch?v=localized" { + t.Fatalf("expected traileryoutube URL from TMDB localized trailer, got %q", got) + } +} + +func TestResolveOverviewUsesScopedTVOverviewOnlyForEpisodeOrSeasonPack(t *testing.T) { + t.Parallel() + + ptBR := api.TMDBLocalizedData{ + Overview: "Series Overview", + EpisodeOverview: "Episode Overview", + } + + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "episode upload uses episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Episode Overview", + }, + { + name: "lowercase episode upload uses episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "tv"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Episode Overview", + }, + { + name: "season pack uses season overview from episode field", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + TVPack: true, + }, + want: "Episode Overview", + }, + { + name: "series upload uses title overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + }, + want: "Series Overview", + }, + { + name: "movie ignores episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Series Overview", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := resolveOverview(tc.meta, ptBR); got != tc.want { + t.Fatalf("expected overview %q, got %q", tc.want, got) + } + }) + } +} + +func TestBuildDescriptionOmitsBlankEpisodeTitle(t *testing.T) { + t.Parallel() + + req := trackers.PreparationInput{Meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": { + EpisodeOverview: "Overview Localizado", + }, + }, + }, + }, + }} + + got := buildDescription(req, trackers.DescriptionAssets{}) + if strings.Contains(got, "[align=center][/align]") { + t.Fatalf("expected blank episode title row to be omitted, got %q", got) + } + if !strings.Contains(got, "[align=center]Overview Localizado[/align]") { + t.Fatalf("expected localized episode overview row, got %q", got) + } +} + +func TestBuildDescriptionKeepsEpisodeTitleWhenPresent(t *testing.T) { + t.Parallel() + + req := trackers.PreparationInput{Meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": { + EpisodeTitle: "Titulo Localizado", + EpisodeOverview: "Overview Localizado", + }, + }, + }, + }, + }} + + got := buildDescription(req, trackers.DescriptionAssets{}) + if !strings.Contains(got, "[align=center]Titulo Localizado[/align]") { + t.Fatalf("expected localized episode title row, got %q", got) + } + if !strings.Contains(got, "[align=center]Overview Localizado[/align]") { + t.Fatalf("expected localized episode overview row, got %q", got) + } +} + +func TestResolveTagsPreservesUnknownFallbackGenres(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Genre: "Sci-Fi, MyCustomGenre", + }, + } + + got := resolveTags(meta, api.TMDBLocalizedData{}) + expected := "ficcao.cientifica, mycustomgenre" + if got != expected { + t.Fatalf("expected tags %q, got %q", expected, got) + } +} + +func TestResolveTagsFallbackPriority(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{Genre: "ReleaseGenre"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Genres: "Drama"}, + IMDB: &api.IMDBMetadata{Genres: "Comedy"}, + }, + } + + if got := resolveTags(meta, api.TMDBLocalizedData{Genres: "Ação"}); got != "acao" { + t.Fatalf("expected localized tags to win, got %q", got) + } + if got := resolveTags(meta, api.TMDBLocalizedData{}); got != "drama" { + t.Fatalf("expected TMDB tags to beat IMDb and release, got %q", got) + } + + meta.ProviderMetadata.TMDB = nil + if got := resolveTags(meta, api.TMDBLocalizedData{}); got != "comedia" { + t.Fatalf("expected IMDb tags to beat release, got %q", got) + } +} + +func TestBuildFieldsTagsAnswerOverridesComputedTags(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{Genre: "Drama"}, + } + + fields := buildFields(meta, "description", "auth", map[string]string{"tags": "manual.tag"}) + if got := fields["tags"]; got != "manual.tag" { + t.Fatalf("expected manual tags answer, got %q", got) + } +} + +func TestResolveAdultLocalizedAndCanonicalGenres(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "localized Portuguese adult genre", + meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": {Genres: "Adulto"}, + }}, + }, + }, + want: "1", + }, + { + name: "IMDb-only adult genre", + meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Genres: "Adult"}, + }, + }, + want: "1", + }, + { + name: "anime hentai detection remains adult", + meta: api.UploadSubject{ + Anime: true, + Release: api.ReleaseInfo{Genre: "Hentai"}, + }, + want: "1", + }, + { + name: "nonadult Portuguese genre stays nonadult", + meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": {Genres: "Drama"}, + }}, + }, + }, + want: "2", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := resolveAdult(tc.meta); got != tc.want { + t.Fatalf("expected adulto=%q, got %q", tc.want, got) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/bt/bbcode.go b/internal/trackers/impl/standalone/bt/bbcode.go new file mode 100644 index 00000000..e7b21e49 --- /dev/null +++ b/internal/trackers/impl/standalone/bt/bbcode.go @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.RemoveImageResize(value) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/bt/definition.go b/internal/trackers/impl/standalone/bt/definition.go new file mode 100644 index 00000000..8cc2c184 --- /dev/null +++ b/internal/trackers/impl/standalone/bt/definition.go @@ -0,0 +1,25 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "bt", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/bt/dupe.go b/internal/trackers/impl/standalone/bt/dupe.go new file mode 100644 index 00000000..ab357ce1 --- /dev/null +++ b/internal/trackers/impl/standalone/bt/dupe.go @@ -0,0 +1,437 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "sync" + + "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + logger api.Logger +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + logger: logger, + } +} + +func (h dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + cookies, err := loadTrackerCookies(ctx, h.cfg, "BT", trackerHost(trackerBaseURL(h.cfg, "BT", "https://brasiltracker.org"), "brasiltracker.org")) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid BT cookies", nil) + } + + imdbID := resolveBTIMDbIDText(meta) + + if imdbID == "" && !meta.Anime { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for BT dupe search", nil) + } + + isTVPack := meta.TVPack + + searchStr := imdbID + if meta.Anime { + tvdbNameEnglish := "" + tvdbName := "" + if meta.ProviderMetadata.TVDB != nil { + tvdbNameEnglish = strings.TrimSpace(meta.ProviderMetadata.TVDB.NameEnglish) + tvdbName = strings.TrimSpace(meta.ProviderMetadata.TVDB.Name) + } + + tmdbTitle := "" + tmdbOriginalTitle := "" + if meta.ProviderMetadata.TMDB != nil { + tmdbTitle = strings.TrimSpace(meta.ProviderMetadata.TMDB.Title) + tmdbOriginalTitle = strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalTitle) + } + + imdbTitle := "" + if meta.ProviderMetadata.IMDB != nil { + imdbTitle = strings.TrimSpace(meta.ProviderMetadata.IMDB.Title) + } + + releaseName := strings.TrimSpace(meta.ReleaseName) + + switch { + // English + case tvdbNameEnglish != "": + searchStr = tvdbNameEnglish + case tmdbTitle != "": + searchStr = tmdbTitle + case imdbTitle != "": + searchStr = imdbTitle + + // Original + case tvdbName != "": + searchStr = tvdbName + case tmdbOriginalTitle != "": + searchStr = tmdbOriginalTitle + + // Release Name + case releaseName != "": + searchStr = releaseName + } + } + + if searchStr == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing search term for BT dupe search", nil) + } + + baseURL := trackerBaseURL(h.cfg, "BT", "https://brasiltracker.org") + + resp, stringBody, err := doTextGet(ctx, h.http, baseURL+"/torrents.php", url.Values{"searchstr": {searchStr}}, nil, cookies) + if err != nil || !resp.ok() { + return dupe.Failed(dupe.FailureRequest, "BT search request failed", err) + } + if strings.Contains(strings.ToLower(resp.FinalURL), "login") { + return dupe.NotRun(dupe.NotRunAuthNotReady, "BT authentication required", nil) + } + + node, err := html.Parse(strings.NewReader(stringBody)) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "BT search html parse failed", err) + } + + torrentTable := findBTNodeByID(node, "torrent_table") + if torrentTable == nil { + return dupe.Resolved(nil, nil) + } + + groupLinks := make(map[string]struct{}) + findBTGroupLinks(torrentTable, groupLinks) + + if len(groupLinks) == 0 { + return dupe.Resolved(nil, nil) + } + + var foundItems []string + if len(groupLinks) > 0 { + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 5) + linkLoop: + for groupLink := range groupLinks { + select { + case <-ctx.Done(): + break linkLoop + case sem <- struct{}{}: + } + + wg.Add(1) + go func(link string) { + defer wg.Done() + defer func() { <-sem }() + + groupResp, groupBody, groupErr := doTextGet(ctx, h.http, baseURL+"/"+link, nil, nil, cookies) + if groupErr != nil { + if h.logger != nil { + h.logger.Debugf("BT group link request failed for %s: %v", link, groupErr) + } + return + } + if !groupResp.ok() { + if h.logger != nil { + h.logger.Debugf("BT group link request returned non-success status %d for %s", groupResp.StatusCode, link) + } + return + } + + groupNode, nodeErr := html.Parse(strings.NewReader(groupBody)) + if nodeErr != nil { + if h.logger != nil { + h.logger.Debugf("BT group link html parse failed for %s: %v", link, nodeErr) + } + return + } + + var localFound []string + processBTGroupPage(groupNode, isTVPack, &localFound) + + if len(localFound) > 0 { + mu.Lock() + foundItems = append(foundItems, localFound...) + mu.Unlock() + } + }(groupLink) + } + wg.Wait() + } + + var entries []api.DupeEntry + for _, item := range foundItems { + if trimmed := strings.TrimSpace(item); trimmed != "" { + entries = append(entries, api.DupeEntry{Name: trimmed}) + } + } + + if err := ctx.Err(); err != nil { + return dupe.Failed(dupe.FailureRequest, "BT search canceled", err) + } + return dupe.Resolved(entries, nil) +} + +func resolveBTIMDbIDText(meta api.DuplicateSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func findBTNodeByID(n *html.Node, id string) *html.Node { + if n.Type == html.ElementNode { + for _, a := range n.Attr { + if a.Key == "id" && a.Val == id { + return n + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if res := findBTNodeByID(c, id); res != nil { + return res + } + } + return nil +} + +func findBTGroupLinks(n *html.Node, links map[string]struct{}) { + if n.Type == html.ElementNode && n.Data == "a" { + href := "" + for _, a := range n.Attr { + if a.Key == "href" { + href = a.Val + } + } + if strings.Contains(href, "torrents.php?id=") && !strings.Contains(href, "torrentid") { + links[href] = struct{}{} + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + findBTGroupLinks(c, links) + } +} + +func processBTGroupPage(n *html.Node, isTVPack bool, foundItems *[]string) { + trs := findBTAllNodes(n, func(node *html.Node) bool { + if node.Type == html.ElementNode && node.Data == "tr" { + for _, a := range node.Attr { + if a.Key == "id" && strings.HasPrefix(a.Val, "torrent") { + suffix := strings.TrimPrefix(a.Val, "torrent") + if _, err := strconv.Atoi(suffix); err == nil { + return true + } + } + } + } + return false + }) + + for _, tr := range trs { + descLink := findBTNode(tr, func(node *html.Node) bool { + if node.Type == html.ElementNode && node.Data == "a" { + for _, a := range node.Attr { + if a.Key == "onclick" && strings.Contains(a.Val, "gtoggle") { + return true + } + } + } + return false + }) + + if descLink == nil { + continue + } + + descText := strings.ToLower(extractBTText(descLink)) + isDisc := false + for _, kw := range []string{"bd25", "bd50", "bd66", "bd100", "dvd5", "dvd9", "m2ts"} { + if strings.Contains(descText, kw) { + isDisc = true + break + } + } + + idVal := "" + for _, a := range tr.Attr { + if a.Key == "id" { + idVal = a.Val + } + } + torrentID := strings.TrimPrefix(idVal, "torrent") + + fileDiv := findBTNodeByID(n, "files_"+torrentID) + if fileDiv == nil { + continue + } + + if isDisc || isTVPack { + pathDiv := findBTNode(fileDiv, func(node *html.Node) bool { + return node.Type == html.ElementNode && node.Data == "div" && hasBTClass(node, "filelist_path") + }) + if pathDiv != nil { + folderName := strings.Trim(strings.TrimSpace(extractBTText(pathDiv)), "/") + if folderName != "" { + *foundItems = append(*foundItems, folderName) + } + } + } else { + fileTable := findBTNode(fileDiv, func(node *html.Node) bool { + return node.Type == html.ElementNode && node.Data == "table" && hasBTClass(node, "filelist_table") + }) + if fileTable != nil { + rows := findBTAllNodes(fileTable, func(node *html.Node) bool { + return node.Type == html.ElementNode && node.Data == "tr" + }) + for _, row := range rows { + if hasBTClass(row, "colhead_dark") { + continue + } + cell := findBTNode(row, func(node *html.Node) bool { + return node.Type == html.ElementNode && node.Data == "td" + }) + if cell != nil { + filename := strings.TrimSpace(extractBTText(cell)) + if filename != "" { + *foundItems = append(*foundItems, filename) + break + } + } + } + } + } + } +} + +func findBTNode(n *html.Node, match func(*html.Node) bool) *html.Node { + if match(n) { + return n + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if res := findBTNode(c, match); res != nil { + return res + } + } + return nil +} + +func findBTAllNodes(n *html.Node, match func(*html.Node) bool) []*html.Node { + var nodes []*html.Node + if match(n) { + nodes = append(nodes, n) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodes = append(nodes, findBTAllNodes(c, match)...) + } + return nodes +} + +func hasBTClass(n *html.Node, class string) bool { + for _, a := range n.Attr { + if a.Key == "class" { + classes := strings.Fields(a.Val) + if slices.Contains(classes, class) { + return true + } + } + } + return false +} + +func extractBTText(n *html.Node) string { + if n.Type == html.TextNode { + return n.Data + } + var b strings.Builder + for c := n.FirstChild; c != nil; c = c.NextSibling { + b.WriteString(extractBTText(c)) + } + return b.String() +} + +type responseInfo struct { + StatusCode int + FinalURL string +} + +func (r responseInfo) ok() bool { + return r.StatusCode >= http.StatusOK && r.StatusCode < http.StatusMultipleChoices +} +func trackerBaseURL(_ config.Config, _ string, fallback string) string { + return strings.TrimRight(fallback, "/") +} +func trackerHost(baseURL, fallback string) string { + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Hostname() != "" { + return parsed.Hostname() + } + return fallback +} +func loadTrackerCookies(ctx context.Context, cfg config.Config, tracker, domain string) ([]*http.Cookie, error) { + loaded, err := cookies.LoadTrackerHTTPCookies(ctx, cfg.MainSettings.DBPath, tracker, domain) + if err != nil { + return nil, fmt.Errorf("bt: load tracker cookies: %w", err) + } + return loaded, nil +} + +func doTextGet( + ctx context.Context, + client *http.Client, + endpoint string, + params url.Values, + headers map[string]string, + trackerCookies []*http.Cookie, +) (responseInfo, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return responseInfo{}, "", fmt.Errorf("bt: create GET request: %w", err) + } + if len(params) > 0 { + req.URL.RawQuery = params.Encode() + } + req.Header.Set("User-Agent", "upbrr") + for key, value := range headers { + req.Header.Set(key, value) + } + commonhttp.ApplyCookies(req, trackerCookies) + resp, err := client.Do(req) + if err != nil { + return responseInfo{}, "", fmt.Errorf("bt: GET request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return responseInfo{StatusCode: resp.StatusCode}, "", fmt.Errorf("bt: read response: %w", err) + } + info := responseInfo{StatusCode: resp.StatusCode} + if resp.Request != nil && resp.Request.URL != nil { + info.FinalURL = resp.Request.URL.String() + } + return info, string(body), nil +} diff --git a/internal/trackers/impl/standalone/bt/profile.go b/internal/trackers/impl/standalone/bt/profile.go new file mode 100644 index 00000000..4086821d --- /dev/null +++ b/internal/trackers/impl/standalone/bt/profile.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns BT identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "BT", + BaseURL: baseURL, + DescriptionGroup: "bt", + UploadContentMode: trackers.UploadContentModeDescription, + LocalizedMetadataLocale: "pt-BR", + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + AudioPolicy: &trackers.AudioPolicy{AllowBloat: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"t.brasiltracker.org"}}, + AuthCapability: standalone.CookieAuthCapability("BT"), + } +} + +// New returns a fresh BT definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/bt/upload.go b/internal/trackers/impl/standalone/bt/upload.go new file mode 100644 index 00000000..93d812b6 --- /dev/null +++ b/internal/trackers/impl/standalone/bt/upload.go @@ -0,0 +1,1376 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +const ( + baseURL = "https://brasiltracker.org" + uploadURL = baseURL + "/upload.php" + torrentURL = baseURL + "/torrents.php?id=" + sourceFlag = "BT" +) + +var authPattern = regexp.MustCompile(`name="auth"\s+value="([^"]+)"`) +var groupPattern = regexp.MustCompile(`groupid=(\d+)|torrents\.php\?id=(\d+)`) +var mediaInfoDurationLinePattern = regexp.MustCompile(`(?im)^\s*duration(?:\s*/\s*string[123]?)?\s*:\s*(.+)$`) + +var mediaInfoDurationTokenPattern = regexp.MustCompile( + `(?i)(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s)\b`, +) +var isoDurationPattern = regexp.MustCompile(`(?i)^pt(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$`) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string][]string + blockedReason string + questionnaire *api.TrackerQuestionnaire +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: BT %s", state.blockedReason) + } + body, contentType, err := commonhttp.BuildMultipartPayloadMulti(state.fields, []commonhttp.FileField{{ + FieldName: "file_input", + FileName: filepath.Base(state.torrentPath), + Path: state.torrentPath, + }}) + + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "BT") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BT request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BT upload request: %w", err) + } + defer resp.Body.Close() + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode >= 200 && resp.StatusCode < 400, + commonhttp.DefaultResponsePreviewBytes, + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BT read upload response: %w", err) + } + match := groupPattern.FindStringSubmatch(finalURL + "\n" + string(responseBody)) + id := metautil.FirstNonEmptyTrimmed(matchValue(match, 1), matchValue(match, 2)) + if resp.StatusCode >= 200 && resp.StatusCode < 400 && id != "" { + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "BT", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}, + }, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "BT", "upload_failure", responsePreview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("BT", resp.StatusCode, responsePreview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + fields := flattenFields(state.fields) + if _, ok := fields["auth"]; ok { + fields["auth"] = "[redacted]" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "BT", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "bt", + Description: state.description, + Endpoint: uploadURL, + Payload: fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + cookies, err := loadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, err + } + auth := "dry-run-auth" + if !dryRun { + auth, err = fetchAuth(ctx, cookies) + if err != nil { + return uploadState{}, nil, err + } + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + fields := buildFields(req, description, auth, req.TrackerConfig, assets) + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), + fields: fields, + questionnaire: buildQuestionnaire(req.Meta, fields), + } + switch { + case len(fields["image"]) == 0 || strings.TrimSpace(fields["image"][0]) == "": + state.blockedReason = "missing poster URL" + case len(fields["sinopse"]) == 0 || strings.TrimSpace(fields["sinopse"][0]) == "": + state.blockedReason = "missing overview" + case len(fields["tags"]) == 0 || strings.TrimSpace(fields["tags"][0]) == "": + state.blockedReason = "missing tags" + } + + return state, cookies, nil +} + +func buildFields( + req trackers.PreparationInput, + description string, + auth string, + trackerCfg config.TrackerConfig, + assets trackers.DescriptionAssets, +) map[string][]string { + meta := req.Meta + answers := standalone.QuestionnaireAnswers(meta, "BT") + hasPT, subtitleIDs := resolveSubtitle(meta) + width, height := resolveResolution(meta) + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + fields := map[string][]string{ + "audio_c": {resolveAudioCodec(meta)}, + "audio": {resolveAudio(meta)}, + "auth": {auth}, + "bitrate": {resolveBitrate(meta)}, + "desc": {""}, + "diretor": {resolveDirectors(meta)}, + "duracao": {fmt.Sprintf("%d min", resolveRuntime(meta))}, + "especificas": {description}, + "format": {resolveContainer(meta)}, + "idioma_ori": {resolveLanguage(meta)}, + "image": {resolvePoster(meta)}, + "legenda": {hasPT}, + "mediainfo": {trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta)}, + "resolucao_1": {width}, + "resolucao_2": {height}, + "sinopse": {metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["overview"]), resolveOverview(meta, ptBR))}, + "submit": {"true"}, + "tags": {metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["tags"]), resolveTags(meta, ptBR))}, + "title": {resolveTitle(meta)}, + "type": {resolveType(meta)}, + "video_c": {resolveVideoCodec(meta)}, + "year": {strconv.Itoa(resolveYear(meta))}, + "youtube": {resolveYouTube(meta, ptBR)}, + } + + fields["subtitles[]"] = append(fields["subtitles[]"], subtitleIDs...) + + screens := resolveScreens(assets) + fields["screen[]"] = append(fields["screen[]"], screens...) + + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + if !meta.Anime && (category == "MOVIE" || category == "TV") { + fields["3d"] = []string{yesNo(meta.Is3D != "")} + fields["adulto"] = []string{"0"} + fields["imdb_input"] = []string{resolveIMDbText(meta)} + fields["nota_imdb"] = []string{resolveIMDbRating(meta)} + fields["title_br"] = []string{resolveLocalizedTitle(meta, ptBR)} + } + if meta.Scene { + fields["scene"] = []string{"on"} + } + if category == "TV" || meta.Anime { + fields["episodio"] = []string{meta.EpisodeStr} + fields["ntorrent"] = []string{meta.SeasonStr + meta.EpisodeStr} + if meta.TVPack { + fields["temporada"] = []string{meta.SeasonStr} + fields["tipo"] = []string{"completa"} + } else { + fields["temporada_e"] = []string{meta.SeasonStr} + fields["tipo"] = []string{"ep_individual"} + } + } + if category == "MOVIE" { + fields["versao"] = []string{resolveEdition(meta)} + } + if meta.Anime { + fields["fundo_torrent"] = []string{resolveBackdrop(meta)} + fields["rating"] = []string{resolveIMDbRating(meta)} + fields["releasedate"] = []string{strconv.Itoa(resolveYear(meta))} + fields["horas"] = []string{""} + fields["minutos"] = []string{""} + fields["vote"] = []string{""} + } + if trackerCfg.Anon { + fields["anonymous"] = []string{"1"} + } + if trackers.IsInternalGroup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"BT": trackerCfg}}}, "BT", meta) { + fields["internal"] = []string{"1"} + } + return fields +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + meta := req.Meta + var parts []string + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if logo := resolveLogo(meta); logo != "" { + parts = append(parts, "[center][img]"+logo+"[/img][/center]") + } + + // TV Episode details + epTitle := meta.EpisodeTitle + epOverview := meta.EpisodeOverview + ptBR := api.ExtractTrackerLocalizedPTBR(meta) + if ptBR.EpisodeTitle != "" { + epTitle = ptBR.EpisodeTitle + } + if ptBR.EpisodeOverview != "" { + epOverview = ptBR.EpisodeOverview + } + if episode := strings.TrimSpace(epOverview); episode != "" { + if title := strings.TrimSpace(epTitle); title != "" { + parts = append(parts, "[center]"+title+"[/center]") + } + parts = append(parts, "[center]"+episode+"[/center]") + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // Signature + link, _ := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[center][url=%s]Upload realizado via %s[/url][/center]", link, "upbrr")) + + // Join and finalize + description := strings.Join(parts, "\n\n") + finalized := finalizeDescription(description) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "BT", req.Runtime.DBPath, finalized, req.Logger) + } + + return finalized +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "BT", "brasiltracker.org") + if err != nil { + return values, fmt.Errorf("trackers: BT load cookies: %w", err) + } + return values, nil +} + +func fetchAuth(ctx context.Context, cookies []*http.Cookie) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uploadURL, nil) + if err != nil { + return "", fmt.Errorf("trackers: BT auth token request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(req, cookies) + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) + if err != nil { + return "", fmt.Errorf("trackers: BT auth token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + match := authPattern.FindStringSubmatch(string(body)) + if len(match) < 2 { + return "", errors.New("trackers: BT auth token not found") + } + return strings.TrimSpace(match[1]), nil +} + +func resolveType(meta api.UploadSubject) string { + if meta.Anime { + return "5" + } + if strings.EqualFold(categoryOf(meta), "TV") { + return "1" + } + return "0" +} + +func resolveContainer(meta api.UploadSubject) string { + container := strings.ToLower(strings.TrimSpace(meta.Container)) + switch container { + case "avi", "m2ts", "m4v", "mkv", "mp4", "ts", "vob", "wmv": + return strings.ToUpper(container) + default: + return "Outro" + } +} + +func resolveAudio(meta api.UploadSubject) string { + pt := false + for _, lang := range meta.AudioLanguages { + lower := strings.ToLower(strings.TrimSpace(lang)) + if lower == "portuguese" || lower == "português" || lower == "pt" { + pt = true + break + } + } + orig := "" + if meta.ProviderMetadata.TMDB != nil { + orig = strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage)) + } + if pt { + if orig == "pt" { + return "Nacional" + } + if len(meta.AudioLanguages) > 1 { + return "Dual Audio" + } + return "Dublado" + } + return "Legendado" +} + +var targetSiteIDs = map[string]string{ + "arabic": "22", + "bulgarian": "29", + "chinese": "14", + "croatian": "23", + "czech": "30", + "danish": "10", + "dutch": "9", + "english - forçada": "50", + "english": "3", + "estonian": "38", + "finnish": "15", + "french": "5", + "german": "6", + "greek": "26", + "hebrew": "40", + "hindi": "41", + "hungarian": "24", + "icelandic": "28", + "indonesian": "47", + "italian": "16", + "japanese": "8", + "korean": "19", + "latvian": "37", + "lithuanian": "39", + "norwegian": "12", + "persian": "52", + "polish": "17", + "português": "49", + "romanian": "13", + "russian": "7", + "serbian": "31", + "slovak": "42", + "slovenian": "43", + "spanish": "4", + "swedish": "11", + "thai": "20", + "turkish": "18", + "ukrainian": "34", + "vietnamese": "25", +} + +var sourceAliasMap = map[string]string{ + "arabic": "arabic", + "ara": "arabic", + "ar": "arabic", + "brazilian portuguese": "português", + "brazilian": "português", + "portuguese-br": "português", + "pt-br": "português", + "portuguese": "português", + "por": "português", + "pt": "português", + "pt-pt": "português", + "português brasileiro": "português", + "português": "português", + "bulgarian": "bulgarian", + "bul": "bulgarian", + "bg": "bulgarian", + "chinese": "chinese", + "chi": "chinese", + "zh": "chinese", + "chinese (simplified)": "chinese", + "chinese (traditional)": "chinese", + "cmn-hant": "chinese", + "cmn-hans": "chinese", + "yue-hant": "chinese", + "yue-hans": "chinese", + "croatian": "croatian", + "hrv": "croatian", + "hr": "croatian", + "scr": "croatian", + "czech": "czech", + "cze": "czech", + "cz": "czech", + "cs": "czech", + "danish": "danish", + "dan": "danish", + "da": "danish", + "dutch": "dutch", + "dut": "dutch", + "nl": "dutch", + "english - forced": "english - forçada", + "english (forced)": "english - forçada", + "en (forced)": "english - forçada", + "en-us (forced)": "english - forçada", + "english": "english", + "eng": "english", + "en": "english", + "en-us": "english", + "en-gb": "english", + "english (cc)": "english", + "english - sdh": "english", + "estonian": "estonian", + "est": "estonian", + "et": "estonian", + "finnish": "finnish", + "fin": "finnish", + "fi": "finnish", + "french": "french", + "fre": "french", + "fr": "french", + "fr-fr": "french", + "fr-ca": "french", + "german": "german", + "ger": "german", + "de": "german", + "greek": "greek", + "gre": "greek", + "el": "greek", + "hebrew": "hebrew", + "heb": "hebrew", + "he": "hebrew", + "hindi": "hindi", + "hin": "hindi", + "hi": "hindi", + "hungarian": "hungarian", + "hun": "hungarian", + "hu": "hungarian", + "icelandic": "icelandic", + "ice": "icelandic", + "is": "icelandic", + "indonesian": "indonesian", + "ind": "indonesian", + "id": "indonesian", + "italian": "italian", + "ita": "italian", + "it": "italian", + "japanese": "japanese", + "jpn": "japanese", + "ja": "japanese", + "korean": "korean", + "kor": "korean", + "ko": "korean", + "latvian": "latvian", + "lav": "latvian", + "lv": "latvian", + "lithuanian": "lithuanian", + "lit": "lithuanian", + "lt": "lithuanian", + "norwegian": "norwegian", + "nor": "norwegian", + "no": "norwegian", + "persian": "persian", + "fa": "persian", + "far": "persian", + "polish": "polish", + "pol": "polish", + "pl": "polish", + "romanian": "romanian", + "rum": "romanian", + "ro": "romanian", + "russian": "russian", + "rus": "russian", + "ru": "russian", + "serbian": "serbian", + "srp": "serbian", + "sr": "serbian", + "scc": "serbian", + "slovak": "slovak", + "slo": "slovak", + "sk": "slovak", + "slovenian": "slovenian", + "slv": "slovenian", + "sl": "slovenian", + "spanish": "spanish", + "spa": "spanish", + "es": "spanish", + "es-es": "spanish", + "es-419": "spanish", + "swedish": "swedish", + "swe": "swedish", + "sv": "swedish", + "thai": "thai", + "tha": "thai", + "th": "thai", + "turkish": "turkish", + "tur": "turkish", + "tr": "turkish", + "ukrainian": "ukrainian", + "ukr": "ukrainian", + "uk": "ukrainian", + "vietnamese": "vietnamese", + "vie": "vietnamese", + "vi": "vietnamese", +} + +func resolveSubtitle(meta api.UploadSubject) (string, []string) { + hasPT := "Nao" + ids := make([]string, 0) + seen := make(map[string]struct{}) + + for _, lang := range meta.SubtitleLanguages { + cleanLang := strings.ToLower(strings.TrimSpace(lang)) + + targetKey, ok := sourceAliasMap[cleanLang] + if !ok { + targetKey = cleanLang + } + + if id, exists := targetSiteIDs[targetKey]; exists { + if _, alreadySeen := seen[id]; !alreadySeen { + seen[id] = struct{}{} + ids = append(ids, id) + + if id == "49" { + hasPT = "Sim" + } + } + } + } + + if len(ids) == 0 { + return "Nao", []string{"44"} + } + + return hasPT, ids +} + +func resolveResolution(meta api.UploadSubject) (string, string) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + heightStr := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(meta.Release.Resolution), "p"), "i") + heightNum, err := strconv.Atoi(heightStr) + if err == nil && heightNum > 0 { + widthNum := int(math.Round((16.0 / 9.0) * float64(heightNum))) + return strconv.Itoa(widthNum), strconv.Itoa(heightNum) + } + } + + if meta.MediaInfoJSONPath != "" { + if payload, err := os.ReadFile(meta.MediaInfoJSONPath); err == nil { + type mediaInfoDoc struct { + Media struct { + Track []map[string]any `json:"track"` + } `json:"media"` + } + var doc mediaInfoDoc + if err := json.Unmarshal(payload, &doc); err == nil { + for _, track := range doc.Media.Track { + trackType, _ := track["@type"].(string) + if strings.ToLower(trackType) == "video" { + widthVal := track["Width"] + heightVal := track["Height"] + + widthStr := parseDimensionStr(widthVal) + heightStr := parseDimensionStr(heightVal) + + if widthStr != "" && heightStr != "" { + return widthStr, heightStr + } + } + } + } + } + } + + height := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(meta.Release.Resolution), "p"), "i") + switch height { + case "2160": + return "3840", "2160" + case "1080": + return "1920", "1080" + case "720": + return "1280", "720" + case "576": + return "1024", "576" + case "480": + return "854", "480" + default: + return "", "" + } +} + +func parseDimensionStr(val any) string { + return metautil.ParseDimensionStr(val) +} + +func resolveVideoCodec(meta api.UploadSubject) string { + videoEncode := strings.ToLower(strings.TrimSpace(meta.VideoEncode)) + codecFinal := strings.TrimSpace(meta.VideoCodec) + isHDR := meta.HDR != "" + + encodeMap := []struct{ Key, Value string }{ + {"x265", "x265"}, + {"h.265", "H.265"}, + {"x264", "x264"}, + {"h.264", "H.264"}, + {"vp9", "VP9"}, + {"xvid", "XviD"}, + } + + for _, item := range encodeMap { + if strings.Contains(videoEncode, item.Key) { + if (item.Value == "x265" || item.Value == "H.265") && isHDR { + return item.Value + " HDR" + } + return item.Value + } + } + + codecLower := strings.ToLower(codecFinal) + codecMap := []struct{ Key, Value string }{ + {"hevc", "x265"}, + {"265", "x265"}, + {"avc", "x264"}, + {"264", "x264"}, + {"mpeg-2", "MPEG-2"}, + {"vc-1", "VC-1"}, + } + + for _, item := range codecMap { + if strings.Contains(codecLower, item.Key) { + if item.Value == "x265" && isHDR { + return "x265 HDR" + } + return item.Value + } + } + + if codecFinal != "" { + return codecFinal + } + return "Outro" +} + +func resolveAudioCodec(meta api.UploadSubject) string { + priorityOrder := []string{ + "DTS-X", "E-AC-3 JOC", "TrueHD", "DTS-HD", "PCM", "FLAC", "DTS-ES", + "DTS", "E-AC-3", "AC3", "AAC", "Opus", "Vorbis", "MP3", "MP2", + } + + codecMap := map[string][]string{ + "DTS-X": {"DTS:X"}, + "E-AC-3 JOC": {"DD+ 5.1 Atmos", "DD+ 7.1 Atmos", "ATMOS"}, + "TrueHD": {"TRUEHD"}, + "DTS-HD": {"DTS-HD"}, + "PCM": {"LPCM", "PCM"}, + "FLAC": {"FLAC"}, + "DTS-ES": {"DTS-ES"}, + "DTS": {"DTS"}, + "E-AC-3": {"DD+", "E-AC-3"}, + "AC3": {"DD", "AC3"}, + "AAC": {"AAC"}, + "Opus": {"OPUS"}, + "Vorbis": {"VORBIS"}, + "MP2": {"MP2"}, + "MP3": {"MP3"}, + } + + audioDescription := strings.ToUpper(strings.TrimSpace(meta.Audio)) + if audioDescription == "" { + return "Outro" + } + + for _, codecName := range priorityOrder { + searchTerms := codecMap[codecName] + for _, term := range searchTerms { + if strings.Contains(audioDescription, term) { + return codecName + } + } + } + + return "Outro" +} + +func resolveBitrate(meta api.UploadSubject) string { + discType := strings.ToUpper(strings.TrimSpace(meta.DiscType)) + if strings.ToUpper(strings.TrimSpace(meta.Type)) == "DISC" || discType == "BDMV" || discType == "DVD" { + if discType == "BDMV" { + size := meta.SourceSize + switch { + case size > 66000000000: + return "BD100" + case size > 50000000000: + return "BD66" + case size > 25000000000: + return "BD50" + default: + return "BD25" + } + } + if discType == "DVD" { + dvdSize := strings.ToUpper(strings.TrimSpace(meta.Release.Size)) + if dvdSize == "DVD9" || dvdSize == "DVD5" { + return dvdSize + } + return "DVD9" + } + } + + sourceType := strings.ToLower(strings.TrimSpace(meta.Type)) + keywordMap := map[string]string{ + "remux": "Remux", + "webdl": "WEB-DL", + "webrip": "WEBRip", + "web": "WEB", + "encode": "Blu-ray", + "bdrip": "BDRip", + "brrip": "BRRip", + "hdtv": "HDTV", + "sdtv": "SDTV", + "dvdrip": "DVDRip", + "hd-dvd": "HD-DVD", + "tvrip": "TVRip", + } + + if val, ok := keywordMap[sourceType]; ok { + return val + } + + source := strings.ToLower(strings.TrimSpace(meta.Release.Source)) + if val, ok := keywordMap[source]; ok { + return val + } + + return "Outro" +} + +func resolveEdition(meta api.UploadSubject) string { + edition := strings.ToLower(strings.TrimSpace(meta.Edition)) + switch { + case strings.Contains(edition, "director"): + return "Director's Cut" + case strings.Contains(edition, "theatrical"): + return "Theatrical Cut" + case strings.Contains(edition, "extended"): + return "Extended" + case strings.Contains(edition, "uncut"): + return "Uncut" + case strings.Contains(edition, "unrated"): + return "Unrated" + case strings.Contains(edition, "imax"): + return "IMAX" + case strings.Contains(edition, "noir"): + return "Noir" + case strings.Contains(edition, "remaster"): + return "Remastered" + default: + return "" + } +} + +func removeDiacritics(s string) string { + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + result, _, _ := transform.String(t, s) + return result +} + +func resolveTags(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + // 1. Use localized if available + if ptBR.Genres != "" { + genres := strings.Split(strings.TrimSpace(ptBR.Genres), ",") + out := make([]string, 0, len(genres)) + for _, genre := range genres { + cleaned := removeDiacritics(genre) + tag := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(cleaned), " ", ".")) + if tag != "" { + out = append(out, tag) + } + } + return strings.Join(out, ", ") + } + + // 2. Use metautil.TranslateGenreToPortugueseStrict to translate + var genreText string + switch { + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) != "": + genreText = strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + default: + genreText = strings.TrimSpace(meta.Release.Genre) + } + + if genreText == "" { + return "" + } + + genres := strings.Split(genreText, ",") + out := make([]string, 0, len(genres)) + for _, genre := range genres { + translated := metautil.TranslateGenreToPortugueseStrict(genre) + if translated == "" { + translated = genre + } + cleaned := removeDiacritics(translated) + tag := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(cleaned), " ", ".")) + if tag != "" { + out = append(out, tag) + } + } + return strings.Join(out, ", ") +} + +func resolveRuntime(meta api.UploadSubject) int { + if meta.MediaInfoTextPath != "" { + if payload, err := os.ReadFile(meta.MediaInfoTextPath); err == nil { + if minutes := parseMediaInfoDurationMinutes(string(payload)); minutes > 0 { + return minutes + } + } + } + if meta.DVDVOBMediaInfoText != "" { + if minutes := parseMediaInfoDurationMinutes(meta.DVDVOBMediaInfoText); minutes > 0 { + return minutes + } + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") && meta.Disc.DurationSeconds > 0 { + return int(math.Round(meta.Disc.DurationSeconds / 60)) + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.RuntimeMinutes > 0 { + return meta.ProviderMetadata.IMDB.RuntimeMinutes + } + if meta.ProviderMetadata.TMDB != nil { + return meta.ProviderMetadata.TMDB.Runtime + } + if meta.ProviderMetadata.TVmaze != nil { + return meta.ProviderMetadata.TVmaze.Runtime + } + return 0 +} + +// parseMediaInfoDurationMinutes returns rounded minutes from the first parseable +// MediaInfo Duration or Duration/String[1-3] line. +func parseMediaInfoDurationMinutes(content string) int { + for _, match := range mediaInfoDurationLinePattern.FindAllStringSubmatch(content, -1) { + if len(match) != 2 { + continue + } + if minutes := parseMediaInfoDurationValueMinutes(match[1]); minutes > 0 { + return minutes + } + } + return 0 +} + +// parseMediaInfoDurationValueMinutes accepts colon time, unit-token text, or +// raw millisecond values as emitted by MediaInfo duration fields. +func parseMediaInfoDurationValueMinutes(value string) int { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0 + } + if matches := isoDurationPattern.FindStringSubmatch(trimmed); len(matches) == 4 { + return mediaInfoDurationSecondsToMinutes(durationComponentSeconds(matches[1], matches[2], matches[3], "")) + } + if strings.Contains(trimmed, ":") { + return mediaInfoDurationSecondsToMinutes(parseMediaInfoDurationColonSeconds(trimmed)) + } + if seconds := parseMediaInfoDurationTokenSeconds(trimmed); seconds > 0 { + return mediaInfoDurationSecondsToMinutes(seconds) + } + if fields := strings.Fields(trimmed); len(fields) > 0 { + if ms, err := strconv.ParseFloat(strings.ReplaceAll(fields[0], ",", ""), 64); err == nil && ms > 10000 { + return int(math.Round(ms / 60000.0)) + } + } + return 0 +} + +// parseMediaInfoDurationTokenSeconds sums h/m/s/ms duration tokens into seconds. +func parseMediaInfoDurationTokenSeconds(value string) float64 { + var total float64 + for _, match := range mediaInfoDurationTokenPattern.FindAllStringSubmatch(value, -1) { + if len(match) != 3 { + continue + } + amount, err := strconv.ParseFloat(strings.ReplaceAll(match[1], ",", ""), 64) + if err != nil || amount <= 0 { + continue + } + switch strings.ToLower(match[2]) { + case "h", "hr", "hrs", "hour", "hours": + total += amount * 3600 + case "m", "min", "mins", "minute", "minutes": + total += amount * 60 + case "s", "sec", "secs", "second", "seconds": + total += amount + case "ms", "msec", "msecs", "millisecond", "milliseconds": + total += amount / 1000 + } + } + return total +} + +// parseMediaInfoDurationColonSeconds parses MediaInfo colon duration values into seconds. +func parseMediaInfoDurationColonSeconds(value string) float64 { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) < 2 { + return 0 + } + var seconds float64 + multiplier := 1.0 + for i := len(parts) - 1; i >= 0; i-- { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + amount, err := strconv.ParseFloat(strings.ReplaceAll(part, ",", ""), 64) + if err != nil || amount < 0 { + return 0 + } + seconds += amount * multiplier + multiplier *= 60 + } + return seconds +} + +func mediaInfoDurationSecondsToMinutes(seconds float64) int { + if seconds <= 0 { + return 0 + } + return int(math.Round(seconds / 60.0)) +} + +func durationComponentSeconds(hours string, minutes string, seconds string, milliseconds string) float64 { + totalSeconds := parseDurationComponent(hours) * 3600 + totalSeconds += parseDurationComponent(minutes) * 60 + totalSeconds += parseDurationComponent(seconds) + totalSeconds += parseDurationComponent(milliseconds) / 1000 + return totalSeconds +} + +func parseDurationComponent(value string) float64 { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0 + } + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func resolveDirectors(meta api.UploadSubject) string { + var directors []string + seen := make(map[string]struct{}) + + addDirector := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + directors = append(directors, name) + } + } + + if meta.ProviderMetadata.TMDB != nil { + for _, name := range meta.ProviderMetadata.TMDB.Directors { + addDirector(name) + } + } + if meta.ProviderMetadata.IMDB != nil { + for _, person := range meta.ProviderMetadata.IMDB.Directors { + addDirector(person.Name) + } + } + + if len(directors) > 0 { + limit := min(len(directors), 5) + return strings.Join(directors[:limit], ", ") + } + + return "N/A" +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + if meta.ProviderMetadata.TMDB.Localized != nil { + if localized, ok := meta.ProviderMetadata.TMDB.Localized["pt-BR"]; ok && strings.TrimSpace(localized.Poster) != "" { + return strings.TrimSpace(localized.Poster) + } + } + if strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + } + if meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) != "" { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + } + return "" +} + +func resolveScreens(assets trackers.DescriptionAssets) []string { + var screens []string + seen := make(map[string]struct{}) + + for _, image := range assets.MenuImages { + u := strings.TrimSpace(image.RawURL) + if u == "" { + u = strings.TrimSpace(image.ImgURL) + } + if u != "" && !isSeen(seen, u) { + screens = append(screens, u) + seen[u] = struct{}{} + } + } + for _, image := range assets.Screenshots { + u := strings.TrimSpace(image.RawURL) + if u == "" { + u = strings.TrimSpace(image.ImgURL) + } + if u != "" && !isSeen(seen, u) { + screens = append(screens, u) + seen[u] = struct{}{} + } + } + return screens +} + +func isSeen(seen map[string]struct{}, url string) bool { + _, ok := seen[url] + return ok +} + +// resolveOverview prefers scoped TV synopsis for episode/season-pack uploads, +// then localized title-level overview, then TMDB or IMDB fallback text. +func resolveOverview(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + if shouldUseScopedTVOverview(meta) && ptBR.EpisodeOverview != "" { + return strings.TrimSpace(ptBR.EpisodeOverview) + } + if ptBR.Overview != "" { + return strings.TrimSpace(ptBR.Overview) + } + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + } + return "" +} + +// shouldUseScopedTVOverview reports whether BT should prefer season or +// episode localized overview over title-level synopsis text. +func shouldUseScopedTVOverview(meta api.UploadSubject) bool { + if meta.SeasonInt <= 0 { + return false + } + if !isTVUpload(meta) { + return false + } + if meta.TVPack { + return true + } + return meta.EpisodeInt > 0 +} + +// isTVUpload reports whether canonical identity classifies the upload as TV. +func isTVUpload(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func resolveYouTube(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + youtube := "" + if ptBR.TrailerURL != "" { + youtube = strings.TrimSpace(ptBR.TrailerURL) + } else if meta.ProviderMetadata.TMDB != nil { + youtube = strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + + if strings.Contains(youtube, "youtube.com") || strings.Contains(youtube, "youtu.be") { + switch { + case strings.Contains(youtube, "v="): + parts := strings.Split(youtube, "v=") + if len(parts) > 1 { + youtube = parts[1] + } + case strings.Contains(youtube, "embed/"): + parts := strings.Split(youtube, "embed/") + if len(parts) > 1 { + youtube = parts[1] + } + case strings.Contains(youtube, "youtu.be/"): + parts := strings.Split(youtube, "youtu.be/") + if len(parts) > 1 { + youtube = parts[1] + } + } + } + + if idx := strings.Index(youtube, "&"); idx != -1 { + youtube = youtube[:idx] + } + if idx := strings.Index(youtube, "?"); idx != -1 { + youtube = youtube[:idx] + } + youtube = strings.ReplaceAll(youtube, "/", "") + return youtube +} + +func resolveLogo(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo) != "" { + return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo), "/") + } + return "" +} + +func resolveYear(meta api.UploadSubject) int { + if meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0 { + return meta.ProviderMetadata.TMDB.Year + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0 { + return meta.ProviderMetadata.IMDB.Year + } + return meta.Release.Year +} + +func resolveTitle(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Title, meta.Release.Title) + } + return meta.Release.Title +} + +func resolveLocalizedTitle(meta api.UploadSubject, ptBR api.TMDBLocalizedData) string { + if ptBR.Title != "" { + if meta.ProviderMetadata.TMDB != nil { + return metautil.FirstNonEmptyTrimmed(ptBR.Title, meta.ProviderMetadata.TMDB.OriginalTitle) + } + return ptBR.Title + } + if meta.ProviderMetadata.TMDB != nil { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Title, meta.ProviderMetadata.TMDB.OriginalTitle) + } + return "" +} + +func resolveLanguage(meta api.UploadSubject) string { + var lang string + if meta.ProviderMetadata.TMDB != nil { + lang = strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) + } + if lang == "" { + if len(meta.Release.Language) > 0 { + lang = meta.Release.Language[0] + } + } + lang = strings.ToLower(lang) + if lang == "" { + return "" + } + + return metautil.ISO639PortugueseName(lang, lang) +} + +func resolveBackdrop(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Backdrop) + } + return "" +} + +func resolveIMDbText(meta api.UploadSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveIMDbRating(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Rating > 0 { + return strconv.FormatFloat(meta.ProviderMetadata.IMDB.Rating, 'f', 1, 64) + } + return "" +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func yesNo(value bool) string { + if value { + return "Sim" + } + return "Nao" +} + +func flattenFields(in map[string][]string) map[string]string { + out := make(map[string]string, len(in)) + for key, values := range in { + if len(values) > 0 { + out[key] = strings.Join(values, ", ") + } + } + return out +} + +func matchValue(values []string, idx int) string { + if idx >= 0 && idx < len(values) { + return values[idx] + } + return "" +} + +func buildQuestionnaire(meta api.UploadSubject, fields map[string][]string) *api.TrackerQuestionnaire { + current := standalone.QuestionnaireAnswers(meta, "BT") + var items []api.TrackerQuestionnaireField + + sinopse := "" + if len(fields["sinopse"]) > 0 { + sinopse = strings.TrimSpace(fields["sinopse"][0]) + } + if sinopse == "" { + items = append(items, api.TrackerQuestionnaireField{ + Key: "overview", + Label: "Overview", + Kind: "textarea", + Value: current["overview"], + Required: true, + }) + } + + tags := "" + if len(fields["tags"]) > 0 { + tags = strings.TrimSpace(fields["tags"][0]) + } + if tags == "" { + items = append(items, api.TrackerQuestionnaireField{ + Key: "tags", + Label: "Tags", + Kind: "text", + Value: current["tags"], + Required: true, + }) + } + + if len(items) == 0 { + return nil + } + return &api.TrackerQuestionnaire{Tracker: "BT", Fields: items} +} diff --git a/internal/trackers/impl/standalone/bt/upload_test.go b/internal/trackers/impl/standalone/bt/upload_test.go new file mode 100644 index 00000000..b51eec8b --- /dev/null +++ b/internal/trackers/impl/standalone/bt/upload_test.go @@ -0,0 +1,324 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package bt + +import ( + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveOverviewUsesScopedTVOverviewOnlyForEpisodeOrSeasonPack(t *testing.T) { + t.Parallel() + + ptBR := api.TMDBLocalizedData{ + Overview: "Series Overview", + EpisodeOverview: "Episode Overview", + } + + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "episode upload uses episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Episode Overview", + }, + { + name: "lowercase episode upload uses episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "tv"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Episode Overview", + }, + { + name: "season pack uses season overview from episode field", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 1, + TVPack: true, + }, + want: "Episode Overview", + }, + { + name: "series upload uses title overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + }, + want: "Series Overview", + }, + { + name: "movie ignores episode overview", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + SeasonInt: 1, + EpisodeInt: 2, + }, + want: "Series Overview", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := resolveOverview(tc.meta, ptBR); got != tc.want { + t.Fatalf("expected overview %q, got %q", tc.want, got) + } + }) + } +} + +func TestResolveTagsPreservesUnknownGenres(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Release: api.ReleaseInfo{ + Genre: "Sci-Fi,MyCustomGenre", + }, + } + ptBR := api.TMDBLocalizedData{} + + // Sci-Fi maps to "ficção científica", MyCustomGenre does not map but should be preserved + got := resolveTags(meta, ptBR) + expected := "ficcao.cientifica, mycustomgenre" + if got != expected { + t.Fatalf("expected tags %q, got %q", expected, got) + } +} + +func TestBuildDescriptionOmitsBlankLocalizedEpisodeTitleRow(t *testing.T) { + t.Parallel() + + description := buildDescription(trackers.PreparationInput{ + Meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": {EpisodeOverview: "Resumo do episodio"}, + }, + }, + }, + }, + }, trackers.DescriptionAssets{}) + + if strings.Contains(description, "[center][/center]") { + t.Fatalf("expected no blank centered title row, got %q", description) + } + if !strings.Contains(description, "[center]Resumo do episodio[/center]") { + t.Fatalf("expected localized episode overview, got %q", description) + } +} + +func TestBuildDescriptionKeepsLocalizedEpisodeTitleWhenPresent(t *testing.T) { + t.Parallel() + + description := buildDescription(trackers.PreparationInput{ + Meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": { + EpisodeTitle: "Titulo do episodio", + EpisodeOverview: "Resumo do episodio", + }, + }, + }, + }, + }, + }, trackers.DescriptionAssets{}) + + if !strings.Contains(description, "[center]Titulo do episodio[/center]") { + t.Fatalf("expected localized episode title, got %q", description) + } + if !strings.Contains(description, "[center]Resumo do episodio[/center]") { + t.Fatalf("expected localized episode overview, got %q", description) + } +} + +func TestBuildDescriptionTreatsWhitespaceLocalizedEpisodeTitleAsEmpty(t *testing.T) { + t.Parallel() + + description := buildDescription(trackers.PreparationInput{ + Meta: api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Localized: map[string]api.TMDBLocalizedData{ + "pt-BR": { + EpisodeTitle: " \t ", + EpisodeOverview: "Resumo do episodio", + }, + }, + }, + }, + }, + }, trackers.DescriptionAssets{}) + + if strings.Contains(description, "[center][/center]") { + t.Fatalf("expected no blank centered title row, got %q", description) + } + if !strings.Contains(description, "[center]Resumo do episodio[/center]") { + t.Fatalf("expected localized episode overview, got %q", description) + } +} + +func TestResolveVideoCodecMapsH264H265(t *testing.T) { + t.Parallel() + + tests := []struct { + codec string + isHDR bool + expected string + }{ + { + codec: "H264", + isHDR: false, + expected: "x264", + }, + { + codec: "H265", + isHDR: false, + expected: "x265", + }, + { + codec: "H265", + isHDR: true, + expected: "x265 HDR", + }, + { + codec: "hevc", + isHDR: false, + expected: "x265", + }, + { + codec: "avc", + isHDR: false, + expected: "x264", + }, + { + codec: "OtherCodec", + isHDR: false, + expected: "OtherCodec", + }, + } + + for _, tc := range tests { + t.Run(tc.codec, func(t *testing.T) { + meta := api.UploadSubject{ + VideoCodec: tc.codec, + } + if tc.isHDR { + meta.HDR = "HDR10" + } + got := resolveVideoCodec(meta) + if got != tc.expected { + t.Fatalf("expected %q for codec %q (HDR=%t), got %q", tc.expected, tc.codec, tc.isHDR, got) + } + }) + } +} + +func TestParseMediaInfoDurationMinutes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected int + }{ + { + name: "valid hours and minutes", + content: "duration : 2 h 15 m", + expected: 135, + }, + { + name: "duration string label", + content: "Duration/String : 2 h 5 min 2 s", + expected: 125, + }, + { + name: "duration string1 label", + content: "\tDuRaTiOn/String1 : 2 h 5 min 2 s", + expected: 125, + }, + { + name: "duration spaced slash label", + content: "Duration / String2 : 2 hrs 5 mins 2 secs", + expected: 125, + }, + { + name: "duration string3 colon label", + content: "Duration/String3 : 02:05:02.000", + expected: 125, + }, + { + name: "iso duration value", + content: "Duration/String : PT2H5M2S", + expected: 125, + }, + { + name: "valid milliseconds", + content: "duration : 120000", + expected: 2, + }, + { + name: "empty fields (potential panic)", + content: "duration : ", + expected: 0, + }, + { + name: "invalid string1 skips to later duration", + content: "Duration/String1 : not a duration\nDuration/String2 : 01:30:00.000", + expected: 90, + }, + { + name: "duration string4 remains ignored", + content: "Duration/String4 : 01:30:00.000", + expected: 0, + }, + { + name: "adjacent duration fields remain ignored", + content: "Source_Duration : 01:30:00.000\nDuration_Start : 01:30:00.000", + expected: 0, + }, + { + name: "no duration keyword", + content: "something else", + expected: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseMediaInfoDurationMinutes(tc.content) + if got != tc.expected { + t.Fatalf("expected %d, got %d", tc.expected, got) + } + }) + } +} + +func TestResolveRuntimeFallsBackWhenMediaInfoDurationInvalid(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + DVDVOBMediaInfoText: "Duration/String : not a duration", + DiscType: "BDMV", + Disc: api.DiscFacts{DurationSeconds: 5400}, + } + + if got := resolveRuntime(meta); got != 90 { + t.Fatalf("expected BDInfo runtime fallback, got %d", got) + } +} diff --git a/internal/trackers/impl/standalone/btn/claims.go b/internal/trackers/impl/standalone/btn/claims.go new file mode 100644 index 00000000..a9aad8b5 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/claims.go @@ -0,0 +1,1046 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. + "encoding/base32" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + htmlnode "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + btnSiteBaseURL = "https://broadcasthe.net" + btnBackupBaseURL = "https://backup.landof.tv" + btnUserPath = "/user.php" + btnClaimsLoginPath = "/login.php" + btnClaimedShowsURL = "https://broadcasthe.net/forums.php?action=viewthread&threadid=30793" + btnClaimedShowsPostID = "post1405482" + btnClaimedShowsCacheTTL = 48 * time.Hour + btnClaimWindowBaseHours = 48 + btnClaimWindowDefaultGrace = 24 +) + +var ( + btnLineBreakPattern = regexp.MustCompile(`(?i)`) + btnTagPattern = regexp.MustCompile(`(?s)<[^>]*>`) + btnAKAExtractPattern = regexp.MustCompile(`(?i)\(\s*aka\s*:\s*([^\)]*?)\)`) + btnSxxExxCutPattern = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b`) + btnSeasonCutPattern = regexp.MustCompile(`(?i)\bS\d{1,2}\b`) + btnDateCutPattern = regexp.MustCompile(`\b\d{4}[\-\.]\d{2}[\-\.]\d{2}\b`) + btnNonAlnumPattern = regexp.MustCompile(`[^a-z0-9]+`) + btnSpacePattern = regexp.MustCompile(`\s+`) + btnTime24Pattern = regexp.MustCompile(`^(\d{1,2}):(\d{2})(?::(\d{2}))?$`) + btnTime12Pattern = regexp.MustCompile(`^(\d{1,2}):(\d{2})\s*([AaPp][Mm])$`) +) + +type btnClaimedShowsCache struct { + FetchedAt int64 `json:"fetched_at"` + SourceURL string `json:"source_url"` + PostID string `json:"post_id"` + Titles []string `json:"titles"` +} + +type claimChecker struct { + cfg config.Config + logger api.Logger +} + +// NewClaimChecker returns a BTN claim checker backed by the configured claim +// cache and tracker credentials. A nil logger is replaced with a no-op logger. +func (d *Definition) NewClaimChecker(cfg config.Config, logger api.Logger) trackers.ClaimChecker { + if logger == nil { + logger = api.NopLogger{} + } + return &claimChecker{cfg: cfg, logger: logger} +} + +// HasClaim reports whether a TV title appears in BTN's claimed-show list and +// remains inside its claim window. Non-TV content and unavailable claim data +// fail open as unclaimed; malformed or missing air dates keep a matched claim +// active because expiry cannot be established. +func (s *claimChecker) HasClaim(ctx context.Context, meta api.UploadSubject) (bool, error) { + if !btnIsTVCategory(meta) { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims skipped for non-TV content") + } + return false, nil + } + + cachePath, err := btnClaimsPath(s.cfg.MainSettings.DBPath) + if err != nil { + return false, fmt.Errorf("metadata: BTN claims cache path: %w", err) + } + + claimedTitles, err := s.loadBTNClaimedTitles(ctx, cachePath, btnClaimedShowsCacheTTL) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claim list unavailable: %v", err) + } + return false, nil + } + if len(claimedTitles) == 0 { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims loaded 0 titles") + } + return false, nil + } + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims loaded %d titles", len(claimedTitles)) + } + + matched, matchedTitle := matchBTNClaimedTitle(meta, claimedTitles) + if !matched { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims found no title match for release=%q", meta.ReleaseName) + } + return false, nil + } + + graceHours := s.btnClaimWindowGraceHours() + expired, thresholdHours, hoursSinceAir := btnClaimWindowExpired(meta, graceHours) + if expired { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claim window expired for %q (hours_since_air=%.2f threshold=%d)", matchedTitle, hoursSinceAir, thresholdHours) + } + return false, nil + } + + if s.logger != nil { + s.logger.Warnf("metadata: BTN claim match found title=%q threshold_hours=%d cache_ttl=%s", matchedTitle, thresholdHours, btnClaimedShowsCacheTTL) + } + return true, nil +} + +func (s *claimChecker) btnClaimWindowGraceHours() int { + grace := btnClaimWindowDefaultGrace + entry, ok := trackerConfigFor(s.cfg, "BTN") + if !ok || entry.Unknown == nil { + return grace + } + value, ok := entry.Unknown["claim_window_grace_hours"] + if !ok { + return grace + } + parsed := parseOptionalInt(value) + if parsed < 0 { + return 0 + } + if parsed == 0 { + return grace + } + return parsed +} + +func (s *claimChecker) loadBTNClaimedTitles(ctx context.Context, cachePath string, cacheTTL time.Duration) (map[string]struct{}, error) { + cached, fetchedAt, cacheErr := readBTNClaimedCache(cachePath) + if cacheErr != nil { + if s.logger != nil && !errors.Is(cacheErr, os.ErrNotExist) { + s.logger.Warnf("metadata: BTN claims cache read failed path=%s err=%s", cachePath, redaction.RedactValue(cacheErr.Error(), nil)) + } + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims cache loaded path=%s titles=%d fetched_at=%d", cachePath, len(cached), fetchedAt) + } + if len(cached) > 0 && time.Since(time.Unix(fetchedAt, 0)) < cacheTTL { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims cache hit path=%s age=%s ttl=%s", cachePath, time.Since(time.Unix(fetchedAt, 0)).Round(time.Second), cacheTTL) + } + return cached, nil + } + if s.logger != nil { + if len(cached) == 0 { + s.logger.Debugf("metadata: BTN claims cache miss path=%s", cachePath) + } else { + s.logger.Debugf( + "metadata: BTN claims cache stale path=%s age=%s ttl=%s", + cachePath, + time.Since(time.Unix(fetchedAt, 0)).Round(time.Second), + cacheTTL, + ) + } + } + + fresh, fetchErr := s.fetchBTNClaimedTitles(ctx) + if fetchErr == nil && len(fresh) > 0 { + if err := writeBTNClaimedCache(cachePath, fresh); err != nil && s.logger != nil { + s.logger.Warnf("metadata: BTN claims cache write failed: %v", err) + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims cache saved path=%s titles=%d", cachePath, len(fresh)) + } + return fresh, nil + } + if fetchErr == nil && len(fresh) == 0 && s.logger != nil { + s.logger.Warnf("metadata: BTN claims fetch succeeded but returned no titles") + } + if fetchErr != nil && s.logger != nil { + s.logger.Warnf("metadata: BTN claims fetch failed; falling back to cache: %v", fetchErr) + } + if len(cached) > 0 { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims using cached titles after fetch failure path=%s titles=%d", cachePath, len(cached)) + } + return cached, nil + } + return nil, fetchErr +} + +func (s *claimChecker) fetchBTNClaimedTitles(ctx context.Context) (map[string]struct{}, error) { + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims fetch starting url=%s", btnClaimedShowsURL) + } + if err := s.loadBTNCookiesForClaims(ctx, client); err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims cookie load failed: %v", err) + } + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims cookie load completed") + } + isLoggedIn, err := s.btnClaimsSessionValid(ctx, client) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims session validation failed: %v", err) + } + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims session valid=%t", isLoggedIn) + } + if !isLoggedIn { + if err := s.loginBTNForClaims(ctx, client); err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims login failed: %v", err) + } + return nil, err + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login completed") + } + } else if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login skipped; existing session is valid") + } + if !isLoggedIn && s.logger != nil { + s.logger.Debugf("metadata: BTN claims continuing after login attempt") + } + mirrorBTNCookiesForClaimedThread(client) + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims mirrored cookies for broadcasthe thread") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, btnClaimedShowsURL, nil) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims request build failed: %v", err) + } + return nil, fmt.Errorf("metadata: build BTN claims request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims fetch request failed: %v", err) + } + return nil, fmt.Errorf("metadata: execute BTN claims request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims fetch returned status=%d", resp.StatusCode) + } + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims fetch returned status=%d", resp.StatusCode) + } + + payload, err := ioReadAllLimit(resp, 1<<20) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims response read failed: %v", err) + } + return nil, err + } + titles := extractBTNClaimedShows(string(payload)) + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims parsed %d titles from claimed thread", len(titles)) + } + return titles, nil +} + +func (s *claimChecker) loginBTNForClaims(ctx context.Context, client *http.Client) error { + entry, ok := trackerConfigFor(s.cfg, "BTN") + if !ok { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login skipped; tracker not configured") + } + return nil + } + baseURL := resolveBTNLoginBaseURL(entry) + username := strings.TrimSpace(entry.Username) + password := strings.TrimSpace(entry.Password) + if username == "" || password == "" { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login skipped; missing username or password") + } + return nil + } + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login attempting with configured credentials") + } + + values := map[string]string{ + "username": username, + "password": password, + "keeplogged": "1", + "login": "Log In!", + } + if code, err := resolveBTNClaims2FACode(strings.TrimSpace(entry.OTPURI)); err == nil && code != "" { + values["code"] = code + } else if err != nil && s.logger != nil { + s.logger.Warnf("metadata: BTN claims TOTP generation failed: %v", err) + } + encoded := encodeForm(values) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+btnClaimsLoginPath, strings.NewReader(encoded)) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims login request build failed: %v", err) + } + return fmt.Errorf("metadata: build BTN claims login request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "upbrr") + resp, err := client.Do(req) + if err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims login request failed: %v", err) + } + return fmt.Errorf("metadata: execute BTN claims login request: %w", err) + } + _ = resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims login returned status=%d", resp.StatusCode) + } + return fmt.Errorf("login status %d", resp.StatusCode) + } + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login returned status=%d", resp.StatusCode) + } + if valid, err := s.btnClaimsSessionValid(ctx, client); err != nil { + if s.logger != nil { + s.logger.Warnf("metadata: BTN claims post-login validation failed: %v", err) + } + return err + } else if !valid { + return errors.New("BTN login failed to establish a valid session") + } + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims login established a valid session; leaving cookie file unchanged") + } + return nil +} + +func resolveBTNLoginBaseURL(entry config.TrackerConfig) string { + if baseURL := sanitizeBTNWebBaseURL(entry.AnnounceURL); baseURL != "" { + return baseURL + } + if baseURL := sanitizeBTNWebBaseURL(entry.MyAnnounceURL); baseURL != "" { + return baseURL + } + + return btnSiteBaseURL +} + +func (s *claimChecker) loadBTNCookiesForClaims(ctx context.Context, client *http.Client) error { + if client == nil || client.Jar == nil { + return nil + } + + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "BTN", "") + if err != nil { + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims cookie load skipped: %v", err) + } + return nil + } + + if err := setBTNJarCookiesFromNetscape(client, btnSiteBaseURL, trackerCookies); err != nil { + return err + } + if err := setBTNJarCookiesFromNetscape(client, btnBackupBaseURL, trackerCookies); err != nil { + return err + } + + if s.logger != nil { + s.logger.Debugf("metadata: BTN claims loaded %d cookies from shared store", len(trackerCookies)) + } + return nil +} + +func setBTNJarCookiesFromNetscape(client *http.Client, rawURL string, values []*http.Cookie) error { + if client == nil || client.Jar == nil || len(values) == 0 { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("metadata: parse BTN cookie URL: %w", err) + } + + jarCookies := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { + continue + } + copied := *cookie // #nosec G124 -- Outbound tracker jar cookie mirrors imported BTN cookie attributes. + copied.Domain = parsed.Hostname() + if strings.TrimSpace(copied.Path) == "" { + copied.Path = "/" + } + // #nosec G124 -- Outbound tracker jar cookie mirrors imported BTN cookie attributes. + jarCookies = append(jarCookies, &copied) + } + if len(jarCookies) == 0 { + return nil + } + + client.Jar.SetCookies(parsed, jarCookies) + return nil +} + +func (s *claimChecker) btnClaimsSessionValid(ctx context.Context, client *http.Client) (bool, error) { + if client == nil { + return false, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, btnSiteBaseURL+btnUserPath, nil) + if err != nil { + return false, fmt.Errorf("metadata: build BTN claims session validation request: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("metadata: execute BTN claims session validation request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + if strings.Contains(strings.ToLower(finalURL), strings.ToLower(btnClaimsLoginPath)) { + return false, nil + } + return resp.StatusCode >= 200 && resp.StatusCode < 400, nil +} + +func resolveBTNClaims2FACode(otpURI string) (string, error) { + trimmed := strings.TrimSpace(otpURI) + if trimmed == "" { + return "", errors.New("otp_uri not configured") + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("metadata: parse BTN claims otp_uri: %w", err) + } + secret := strings.TrimSpace(parsed.Query().Get("secret")) + if secret == "" { + return "", errors.New("otp_uri missing secret") + } + period := 30 + if value := strings.TrimSpace(parsed.Query().Get("period")); value != "" { + if parsedValue, parseErr := strconv.Atoi(value); parseErr == nil && parsedValue > 0 { + period = parsedValue + } + } + decoder := base32.StdEncoding.WithPadding(base32.NoPadding) + secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) + if err != nil { + return "", fmt.Errorf("metadata: decode BTN otp secret: %w", err) + } + counterTime := time.Now().Unix() / int64(period) + if counterTime < 0 { + return "", errors.New("totp counter before unix epoch") + } + counter := uint64(counterTime) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + mac := hmac.New(sha1.New, secretBytes) + _, _ = mac.Write(buf) + hash := mac.Sum(nil) + offset := hash[len(hash)-1] & 0x0f + code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) + return fmt.Sprintf("%06d", code%1000000), nil +} + +func sanitizeBTNWebBaseURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Host == "" { + return "" + } + if parsed.Scheme == "" { + parsed.Scheme = "https" + } + + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + switch host { + case "", "landof.tv": + return "" + case "broadcasthe.net", "www.broadcasthe.net": + host = "broadcasthe.net" + default: + return "" + } + + lowerPath := strings.ToLower(strings.TrimSpace(parsed.Path)) + if lowerPath != "" && lowerPath != "/" { + return "" + } + + parsed.Host = host + parsed.Path = "" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") +} + +func extractBTNClaimedShows(rawHTML string) map[string]struct{} { + scopedHTML := extractBTNClaimedPostHTML(rawHTML) + normalized := btnLineBreakPattern.ReplaceAllString(scopedHTML, "\n") + normalized = btnTagPattern.ReplaceAllString(normalized, "") + normalized = html.UnescapeString(normalized) + + lines := strings.Split(normalized, "\n") + inCurrentShows := false + out := make(map[string]struct{}) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "current shows:") { + inCurrentShows = true + continue + } + if !inCurrentShows { + continue + } + if strings.Contains(lower, "upcoming shows:") || strings.Contains(lower, "available shows:") { + break + } + if !strings.Contains(trimmed, "--") || !strings.Contains(strings.ToUpper(trimmed), "BTN") { + continue + } + title := strings.TrimSpace(strings.SplitN(trimmed, "--", 2)[0]) + if strings.EqualFold(title, "show") || title == "" { + continue + } + for variant := range btnTitleVariants(title) { + out[variant] = struct{}{} + } + } + return out +} + +func extractBTNClaimedPostHTML(rawHTML string) string { + if strings.TrimSpace(rawHTML) == "" { + return rawHTML + } + + doc, err := htmlnode.Parse(strings.NewReader(rawHTML)) + if err != nil { + return rawHTML + } + + scope := findBTNHTMLNode(doc, func(node *htmlnode.Node) bool { + return node.Type == htmlnode.ElementNode && node.Data == "table" && btnHTMLNodeID(node) == btnClaimedShowsPostID + }) + if scope == nil { + return rawHTML + } + + content := findBTNHTMLNode(scope, func(node *htmlnode.Node) bool { + if node.Type != htmlnode.ElementNode || node.Data != "div" { + return false + } + if btnHTMLNodeID(node) == "content1405482" { + return true + } + return btnHTMLNodeHasClass(node, "postcontent") + }) + if content != nil { + if rendered := renderBTNHTMLChildren(content); rendered != "" { + return rendered + } + } + + if rendered := renderBTNHTMLChildren(scope); rendered != "" { + return rendered + } + + return rawHTML +} + +func findBTNHTMLNode(root *htmlnode.Node, match func(*htmlnode.Node) bool) *htmlnode.Node { + if root == nil { + return nil + } + if match(root) { + return root + } + for child := root.FirstChild; child != nil; child = child.NextSibling { + if found := findBTNHTMLNode(child, match); found != nil { + return found + } + } + return nil +} + +func btnHTMLNodeID(node *htmlnode.Node) string { + return strings.TrimSpace(btnHTMLAttr(node, "id")) +} + +func btnHTMLNodeHasClass(node *htmlnode.Node, className string) bool { + classes := strings.FieldsSeq(strings.TrimSpace(btnHTMLAttr(node, "class"))) + for candidate := range classes { + if strings.EqualFold(candidate, className) { + return true + } + } + return false +} + +func btnHTMLAttr(node *htmlnode.Node, key string) string { + if node == nil { + return "" + } + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, key) { + return attr.Val + } + } + return "" +} + +func renderBTNHTMLChildren(node *htmlnode.Node) string { + if node == nil { + return "" + } + var buf bytes.Buffer + for child := node.FirstChild; child != nil; child = child.NextSibling { + if err := htmlnode.Render(&buf, child); err != nil { + return "" + } + } + return buf.String() +} + +func matchBTNClaimedTitle(meta api.UploadSubject, claimed map[string]struct{}) (bool, string) { + for candidate := range btnCandidateTitles(meta) { + if _, ok := claimed[candidate]; ok { + return true, candidate + } + } + return false, "" +} + +func btnCandidateTitles(meta api.UploadSubject) map[string]struct{} { + candidates := []string{ + meta.ReleaseName, + meta.ReleaseNameNoTag, + meta.Filename, + pathutil.Base(meta.SourcePath), + } + if meta.ProviderMetadata.TVDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TVDB.Name, meta.ProviderMetadata.TVDB.NameEnglish) + } + if meta.ProviderMetadata.TMDB != nil { + candidates = append(candidates, meta.ProviderMetadata.TMDB.Title, meta.ProviderMetadata.TMDB.OriginalTitle) + } + if meta.ProviderMetadata.TVmaze != nil { + candidates = append(candidates, meta.ProviderMetadata.TVmaze.Name) + } + + out := make(map[string]struct{}) + for _, title := range candidates { + trimmed := strings.TrimSpace(title) + if trimmed == "" { + continue + } + for variant := range btnTitleVariants(trimmed) { + out[variant] = struct{}{} + } + derived := btnDeriveShowTitleFromRelease(trimmed) + for variant := range btnTitleVariants(derived) { + out[variant] = struct{}{} + } + } + return out +} + +func btnDeriveShowTitleFromRelease(value string) string { + candidate := strings.ReplaceAll(value, ".", " ") + candidate = strings.ReplaceAll(candidate, "_", " ") + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return "" + } + cutAt := len(candidate) + for _, pattern := range []*regexp.Regexp{btnSxxExxCutPattern, btnSeasonCutPattern, btnDateCutPattern} { + if match := pattern.FindStringIndex(candidate); match != nil && match[0] < cutAt { + cutAt = match[0] + } + } + return strings.TrimSpace(candidate[:cutAt]) +} + +func btnTitleVariants(value string) map[string]struct{} { + canonical, aliases := extractAKAAliases(value) + out := make(map[string]struct{}) + for _, candidate := range append([]string{canonical}, aliases...) { + if normalized := normalizeBTNTitle(candidate); normalized != "" { + out[normalized] = struct{}{} + } + } + return out +} + +func extractAKAAliases(value string) (string, []string) { + cleaned := strings.TrimSpace(value) + if cleaned == "" { + return "", nil + } + match := btnAKAExtractPattern.FindStringSubmatch(cleaned) + if len(match) < 2 { + return cleaned, nil + } + aliases := make([]string, 0) + for _, part := range strings.FieldsFunc(match[1], func(r rune) bool { + switch r { + case ',', '/', ';': + return true + default: + return false + } + }) { + if alias := strings.TrimSpace(part); alias != "" { + aliases = append(aliases, alias) + } + } + cleaned = strings.TrimSpace(btnAKAExtractPattern.ReplaceAllString(cleaned, "")) + return cleaned, aliases +} + +func normalizeBTNTitle(value string) string { + cleaned := strings.TrimSpace(value) + if cleaned == "" { + return "" + } + cleaned = html.UnescapeString(cleaned) + cleaned = strings.ReplaceAll(cleaned, "\u2018", "'") + cleaned = strings.ReplaceAll(cleaned, "\u2019", "'") + cleaned = strings.ReplaceAll(cleaned, "\u0060", "'") + cleaned = strings.ReplaceAll(cleaned, "&", " and ") + cleaned = strings.ToLower(cleaned) + cleaned = btnNonAlnumPattern.ReplaceAllString(cleaned, " ") + cleaned = btnSpacePattern.ReplaceAllString(cleaned, " ") + return strings.TrimSpace(cleaned) +} + +func btnClaimWindowExpired(meta api.UploadSubject, graceHours int) (bool, int, float64) { + airedDate := strings.TrimSpace(meta.TVDBAiredDate) + thresholdHours := btnClaimWindowBaseHours + graceHours + if airedDate == "" { + return false, thresholdHours, 0 + } + airedDateValue, err := time.Parse("2006-01-02", airedDate) + if err != nil { + return false, thresholdHours, 0 + } + + airsTime, hasTime := parseBTNTime(strings.TrimSpace(meta.TVDBAirsTime)) + graceHoursUsed := graceHours + location := time.UTC + if hasTime { + graceHoursUsed = 0 + if tz := strings.TrimSpace(meta.TVDBAirsTimezone); tz != "" { + if loaded, err := time.LoadLocation(tz); err == nil { + location = loaded + } else { + location = time.Now().Location() + } + } else { + location = time.Now().Location() + } + } + thresholdHours = btnClaimWindowBaseHours + graceHoursUsed + + var airedAt time.Time + if hasTime { + airedAt = time.Date( + airedDateValue.Year(), + airedDateValue.Month(), + airedDateValue.Day(), + airsTime.Hour(), + airsTime.Minute(), + airsTime.Second(), + 0, + location, + ) + } else { + airedAt = time.Date(airedDateValue.Year(), airedDateValue.Month(), airedDateValue.Day(), 0, 0, 0, 0, time.UTC) + } + + hoursSinceAir := time.Since(airedAt.UTC()).Hours() + return hoursSinceAir > float64(thresholdHours), thresholdHours, hoursSinceAir +} + +func btnClaimFailureReason(meta api.UploadSubject, graceHours int) string { + expired, thresholdHours, hoursSinceAir := btnClaimWindowExpired(meta, graceHours) + if expired { + return "BTN claim window has expired" + } + if thresholdHours <= 0 { + return "BTN has an active claim for this release" + } + if hoursSinceAir <= 0 { + return fmt.Sprintf("BTN has an active claim for this release; up to %d hours remain in the claim window", thresholdHours) + } + hoursRemaining := max(int(float64(thresholdHours)-hoursSinceAir+0.999999999), 1) + return fmt.Sprintf("BTN has an active claim for this release; approximately %d hours remain in the %d-hour claim window", hoursRemaining, thresholdHours) +} + +func parseBTNTime(value string) (time.Time, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return time.Time{}, false + } + if match := btnTime24Pattern.FindStringSubmatch(trimmed); len(match) == 4 { + hour, _ := strconv.Atoi(match[1]) + minute, _ := strconv.Atoi(match[2]) + second := 0 + if match[3] != "" { + second, _ = strconv.Atoi(match[3]) + } + if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second >= 0 && second <= 59 { + return time.Date(2000, 1, 1, hour, minute, second, 0, time.UTC), true + } + } + if match := btnTime12Pattern.FindStringSubmatch(trimmed); len(match) == 4 { + hour, _ := strconv.Atoi(match[1]) + minute, _ := strconv.Atoi(match[2]) + if hour >= 1 && hour <= 12 && minute >= 0 && minute <= 59 { + hour %= 12 + if strings.EqualFold(match[3], "pm") { + hour += 12 + } + return time.Date(2000, 1, 1, hour, minute, 0, 0, time.UTC), true + } + } + return time.Time{}, false +} + +func readBTNClaimedCache(path string) (map[string]struct{}, int64, error) { + payload, err := os.ReadFile(path) + if err != nil { + return nil, 0, fmt.Errorf("metadata: read BTN claimed cache: %w", err) + } + var cache btnClaimedShowsCache + if err := json.Unmarshal(payload, &cache); err != nil { + return nil, 0, fmt.Errorf("metadata: unmarshal BTN claimed cache: %w", err) + } + titles := make(map[string]struct{}, len(cache.Titles)) + for _, title := range cache.Titles { + if normalized := normalizeBTNTitle(title); normalized != "" { + titles[normalized] = struct{}{} + } + } + return titles, cache.FetchedAt, nil +} + +func writeBTNClaimedCache(path string, titles map[string]struct{}) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("metadata: create BTN claimed cache dir: %w", err) + } + serializedTitles := make([]string, 0, len(titles)) + for title := range titles { + serializedTitles = append(serializedTitles, title) + } + sort.Strings(serializedTitles) + cache := btnClaimedShowsCache{ + FetchedAt: time.Now().Unix(), + SourceURL: btnClaimedShowsURL, + PostID: btnClaimedShowsPostID, + Titles: serializedTitles, + } + encoded, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("metadata: marshal BTN claimed cache: %w", err) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + return fmt.Errorf("metadata: write BTN claimed cache: %w", err) + } + return nil +} + +func parseOptionalInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil { + return parsed + } + } + return 0 +} + +func mirrorBTNCookiesForClaimedThread(client *http.Client) { + if client == nil || client.Jar == nil { + return + } + + backupURL, backupErr := url.Parse("https://backup.landof.tv/") + broadcastURL, broadcastErr := url.Parse("https://broadcasthe.net/") + if backupErr != nil || broadcastErr != nil { + return + } + + backupCookies := client.Jar.Cookies(backupURL) + if len(backupCookies) == 0 { + return + } + + mirrored := make([]*http.Cookie, 0, len(backupCookies)*2) + for _, cookie := range backupCookies { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" { + continue + } + copied := *cookie // #nosec G124 -- Outbound tracker jar cookie mirrors backup BTN session attributes. + copied.Domain = "broadcasthe.net" + if copied.Path == "" { + copied.Path = "/" + } + // #nosec G124 -- Outbound tracker jar cookie mirrors backup BTN session attributes. + mirrored = append(mirrored, &copied) + + dotted := copied // #nosec G124 -- Outbound tracker jar cookie mirrors backup BTN session attributes. + dotted.Domain = ".broadcasthe.net" + // #nosec G124 -- Outbound tracker jar cookie mirrors backup BTN session attributes. + mirrored = append(mirrored, &dotted) + } + if len(mirrored) == 0 { + return + } + client.Jar.SetCookies(broadcastURL, mirrored) +} + +// FailureReason describes an active BTN claim using the configured grace +// period and the remaining or expired claim-window state. +func (s *claimChecker) FailureReason(meta api.UploadSubject) string { + return btnClaimFailureReason(meta, s.btnClaimWindowGraceHours()) +} + +func btnClaimsPath(dbPath string) (string, error) { + path, err := db.FileInSubdir(dbPath, "cache", filepath.Join("banned", "BTN_claimed_releases.json")) + if err != nil { + return "", fmt.Errorf("metadata: resolve tracker claims path: %w", err) + } + return path, nil +} + +func trackerConfigFor(cfg config.Config, tracker string) (config.TrackerConfig, bool) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(tracker)) { + return entry, true + } + } + return config.TrackerConfig{}, false +} + +func btnIsTVCategory(meta api.UploadSubject) bool { + return meta.SeasonInt > 0 || meta.EpisodeInt > 0 || meta.Release.Season > 0 || meta.Release.Episode > 0 || + meta.TVPack || strings.TrimSpace(meta.DailyEpisodeDate) != "" +} + +// NormalizeClaimTitle exposes BTN's claim-title normalization for contract tests. +func NormalizeClaimTitle(value string) string { return normalizeBTNTitle(value) } + +// ExtractClaimedShows exposes BTN's claimed-thread parser for contract tests. +func ExtractClaimedShows(value string) map[string]struct{} { return extractBTNClaimedShows(value) } + +// MirrorCookiesForClaimedThread exposes BTN's backup-domain mirroring behavior for contract tests. +func MirrorCookiesForClaimedThread(client *http.Client) { mirrorBTNCookiesForClaimedThread(client) } + +// ClaimWindowExpired exposes BTN's claim-window calculation for contract tests. +func ClaimWindowExpired(meta api.UploadSubject, graceHours int) (bool, int, float64) { + return btnClaimWindowExpired(meta, graceHours) +} + +// LoadClaimedTitles exposes BTN cache/fetch behavior for contract tests. +func LoadClaimedTitles(ctx context.Context, cfg config.Config, logger api.Logger, cachePath string, cacheTTL time.Duration) (map[string]struct{}, error) { + return (&claimChecker{cfg: cfg, logger: logger}).loadBTNClaimedTitles(ctx, cachePath, cacheTTL) +} + +// FetchClaimedTitles exposes BTN remote claim retrieval for contract tests. +func FetchClaimedTitles(ctx context.Context, cfg config.Config, logger api.Logger) (map[string]struct{}, error) { + return (&claimChecker{cfg: cfg, logger: logger}).fetchBTNClaimedTitles(ctx) +} + +func encodeForm(values map[string]string) string { + encoded := url.Values{} + for key, value := range values { + encoded.Set(key, value) + } + return encoded.Encode() +} + +func ioReadAllLimit(resp *http.Response, maxBytes int64) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, errors.New("nil response body") + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) + if err != nil { + return nil, fmt.Errorf("read limited response body: %w", err) + } + return body, nil +} diff --git a/internal/trackers/impl/standalone/btn/data.go b/internal/trackers/impl/standalone/btn/data.go new file mode 100644 index 00000000..5ed09f50 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/data.go @@ -0,0 +1,105 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +type dataLookup struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDataLookup returns a BTN JSON-RPC lookup bound to cfg and httpClient. +func (d *Definition) NewDataLookup(cfg config.Config, httpClient *http.Client, _ api.Logger) trackers.DataLookup { + return &dataLookup{ + cfg: cfg, + http: httpClient, + endpoint: "https://api.broadcasthe.net/", + } +} + +// Lookup resolves IMDb and TVDB identifiers for a BTN torrent ID. Missing or +// short API tokens, missing tracker IDs, non-success responses, API errors, and +// empty torrent results produce an empty result without an error. +func (l *dataLookup) Lookup(ctx context.Context, req trackers.DataLookupRequest) (trackers.DataLookupResult, error) { + token := strings.TrimSpace(config.ResolveBTNAPIToken(l.cfg)) + trackerID := strings.TrimSpace(req.TrackerID) + if len(token) < 25 || trackerID == "" { + return trackers.DataLookupResult{}, nil + } + payload := map[string]any{ + "jsonrpc": "2.0", + "id": "ua-go", + "method": "getTorrentsSearch", + "params": []any{token, map[string]any{"id": trackerID}, 50}, + } + body, err := json.Marshal(payload) + if err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: btn encode request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(body)) + if err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: btn request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := l.http.Do(httpReq) + if err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: btn request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return trackers.DataLookupResult{}, nil + } + var decoded struct { + Error map[string]any `json:"error"` + Result struct { + Torrents map[string]map[string]any `json:"torrents"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return trackers.DataLookupResult{}, fmt.Errorf("trackerdata: btn decode: %w", err) + } + if len(decoded.Error) > 0 { + return trackers.DataLookupResult{}, nil + } + for _, value := range decoded.Result.Torrents { + return trackers.DataLookupResult{ + TrackerID: trackerID, + IMDBID: int(btnInt(value["ImdbID"])), + TVDBID: int(btnInt(value["TvdbID"])), + }, nil + } + return trackers.DataLookupResult{}, nil +} + +func btnInt(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case int: + return int64(typed) + case int64: + return typed + case string: + parsed, _ := json.Number(strings.TrimSpace(typed)).Int64() + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/btn/data_test.go b/internal/trackers/impl/standalone/btn/data_test.go new file mode 100644 index 00000000..581f72a3 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/data_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" +) + +func TestDataLookup(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/btn" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"torrents": map[string]any{"1": map[string]any{"ImdbID": 1234567, "TvdbID": 76543}}}}) + })) + defer server.Close() + token := strings.Repeat("a", 30) + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"BTN": {APIKey: token}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected BTN data lookup") + } + lookup.endpoint = server.URL + "/btn" + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{TrackerID: "42", OnlyID: true}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if result.IMDBID != 1234567 || result.TVDBID != 76543 { + t.Fatalf("unexpected result: %+v", result) + } +} diff --git a/internal/trackers/impl/standalone/btn/definition.go b/internal/trackers/impl/standalone/btn/definition.go new file mode 100644 index 00000000..740c3c2b --- /dev/null +++ b/internal/trackers/impl/standalone/btn/definition.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "errors" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +// DataLookupConfigured reports whether the BTN API token is available. +func (d *Definition) DataLookupConfigured(cfg config.Config) bool { + return len(config.ResolveBTNAPIToken(cfg)) >= 25 +} + +func bannedGroups() []string { + return []string{ + "3LTON", "4yEo", "7VFr33104D", "AFG", "AniHLS", "AnimeRG", "AniURL", "DeadFish", "ELiTE", "eSc", + "EVO", "FGT", "FUM", "GalaxyTV", "GRANiTEN", "HAiKU", "Hi10", "ION10", "JFF", "JIVE", "LOAD", "MeGusta", + "mSD", "NhaNc3", "NOIVTC", "PHOENiX", "PlaySD", "playXD", "Pr1M371M3", "RAPiDCOWS", "REsuRRecTioN", "RMTeam", + "ROBOTS", "RUBiK", "SPASM", "Telly", "TM", "URANiME", "ViSiON", "W45Ps", "xRed", "XS", "ZKBL", "ZmN", "ZMNT", "[Oj]", + } +} + +func validateBTNRequest(req trackers.PreparationInput) error { + category, err := req.Meta.Identity.RequireCategory() + if err != nil || category != api.CanonicalCategoryTV { + return errors.New("trackers: BTN only supports TV uploads") + } + if strings.TrimSpace(req.Runtime.BTNAPIToken) == "" { + return errors.New("trackers: BTN requires trackers.BTN.api_key") + } + return nil +} + +var btnInternalGroups = []string{ + "BTW", + "ESPNtb", + "HiSD", + "HRiP", + "iPRiP", + "iT00NZ", + "JJ", + "LoTV", + "NTb", + "PreBS", + "RAWR", + "TTVa", + "TVSmash", +} + +func isBTNInternalGroup(meta api.UploadSubject) bool { + if strings.TrimSpace(meta.Tag) == "" { + return false + } + + group := strings.ToLower(strings.TrimPrefix(meta.Tag, "-")) + for _, value := range btnInternalGroups { + if strings.ToLower(value) == group { + return true + } + } + return false +} diff --git a/internal/trackers/impl/standalone/btn/definition_test.go b/internal/trackers/impl/standalone/btn/definition_test.go new file mode 100644 index 00000000..5409790d --- /dev/null +++ b/internal/trackers/impl/standalone/btn/definition_test.go @@ -0,0 +1,1003 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "bytes" + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDefinitionName(t *testing.T) { + t.Parallel() + + def := New() + if def.Name() != "BTN" { + t.Fatalf("expected BTN, got %q", def.Name()) + } +} + +func TestApplyBTNNameMapping(t *testing.T) { + t.Parallel() + + name := "Example.Show.S01E01.1080p.WEB-DL.x265-GRP" + mapped := applyBTNNameMapping(name, "H.265", "WEB-DL") + if mapped == "" { + t.Fatalf("expected mapped name") + } + if mapped != "Example.Show.S01E01.1080p.WEB-DL.H.265-GRP" { + t.Fatalf("unexpected mapped name: %s", mapped) + } +} + +func TestCleanAndNormalizeBTNName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "spaces become dots", + input: "Example Show S01E01 1080p Web-DL DD+ 5.1 x265-GRP", + expected: "Example.Show.S01E01.1080p.Web-DL.DDP5.1.x265-GRP", + }, + { + name: "DDP Atmos compacts before generic DDP channel", + input: "Some.Movie.2023.DDP.5.1.Atmos.x264", + expected: "Some.Movie.2023.DDPA5.1.x264", + }, + { + name: "duplicate dots collapse after DD channel", + input: "Another.Show..S02E03.DD.2.0.x264", + expected: "Another.Show.S02E03.DD2.0.x264", + }, + { + name: "AC3 and DTS channel joins", + input: "Test.AC3.5.1.and.DTS.5.1.Show", + expected: "Test.AC35.1.and.DTS5.1.Show", + }, + { + name: "TrueHD Atmos compacts before generic TrueHD channel", + input: "Movie.TrueHD.7.1.Atmos.x264", + expected: "Movie.TrueHDA7.1.x264", + }, + { + name: "DDP channel joins", + input: "Movie.DDP.5.1.x264", + expected: "Movie.DDP5.1.x264", + }, + { + name: "AAC channel joins", + input: "Movie.AAC.2.0.x264", + expected: "Movie.AAC2.0.x264", + }, + { + name: "FLAC channel joins", + input: "Movie.FLAC.2.0.x264", + expected: "Movie.FLAC2.0.x264", + }, + { + name: "TrueHD channel joins case-insensitively", + input: "Movie.truehd.7.1.x264", + expected: "Movie.TrueHD7.1.x264", + }, + { + name: "PCM channel joins case-insensitively", + input: "Movie.pcm.2.0.x264", + expected: "Movie.PCM2.0.x264", + }, + { + name: "LPCM channel joins case-insensitively", + input: "Movie.lpcm.2.0.x264", + expected: "Movie.LPCM2.0.x264", + }, + { + name: "non-alphanumeric chars become dots", + input: "Movie:Title[Cut].DDP.5.1-GRP", + expected: "Movie.Title.Cut.DDP5.1-GRP", + }, + { + name: "diacritics are removed", + input: "Éxample Shōw S01E01", + expected: "Example.Show.S01E01", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := cleanAndNormalizeBTNName(tc.input) + if result != tc.expected { + t.Errorf("cleanAndNormalizeBTNName(%q) = %q; expected %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestResolveUploadNameGroupTag(t *testing.T) { + tests := []struct { + name string + meta api.UploadSubject + expected string + }{ + { + name: "Valid group tag in meta.Tag", + meta: api.UploadSubject{ + ReleaseName: "Example.Show.S01E01.1080p.Web-DL.x265-GRP", + Tag: "GRP", + }, + expected: "Example.Show.S01E01.1080p.Web-DL.x265-GRP", + }, + { + name: "Valid group tag appended to ReleaseNameNoTag", + meta: api.UploadSubject{ + ReleaseNameNoTag: "Example.Show.S01E01.1080p.Web-DL", + Tag: "GRP", + }, + expected: "Example.Show.S01E01.1080p.Web-DL-GRP", + }, + { + name: "Missing group tag", + meta: api.UploadSubject{ + ReleaseName: "Example.Show.S01E01.1080p.Web-DL.x265", + Tag: "", + }, + expected: "Example.Show.S01E01.1080p.Web-DL.x265-NOGRP", + }, + { + name: "Missing parsed tag preserves existing release suffix", + meta: api.UploadSubject{ + ReleaseName: "Example.Show.S01E01.1080p.Web-DL.x265-GRP", + Tag: "", + }, + expected: "Example.Show.S01E01.1080p.Web-DL.x265-GRP", + }, + { + name: "Unknown group tag in meta.Tag", + meta: api.UploadSubject{ + ReleaseName: "Example.Show.S01E01.1080p.Web-DL.x265", + Tag: "nogrp", + }, + expected: "Example.Show.S01E01.1080p.Web-DL.x265-NOGRP", + }, + { + name: "Existing unknown group tag in ReleaseName", + meta: api.UploadSubject{ + ReleaseName: "Example.Show.S01E01.1080p.Web-DL.x265-unknown", + Tag: "unknown", + }, + expected: "Example.Show.S01E01.1080p.Web-DL.x265-NOGRP", + }, + { + name: "Generated episode title absent from filename stripped", + meta: api.UploadSubject{ + Filename: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP.mkv", + ReleaseName: "Example.Show.S01E01.Episode.One.1080p.WEB-DL.x265-GRP", + EpisodeTitle: "Episode One", + Tag: "GRP", + }, + expected: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", + }, + { + name: "Filename episode title preserved", + meta: api.UploadSubject{ + Filename: "Example.Show.S01E01.Episode.One.1080p.WEB-DL.x265-GRP.mkv", + ReleaseName: "Example.Show.S01E01.Episode.One.1080p.WEB-DL.x265-GRP", + EpisodeTitle: "Episode One", + Tag: "GRP", + }, + expected: "Example.Show.S01E01.Episode.One.1080p.WEB-DL.x265-GRP", + }, + { + name: "Episode title substring preserved", + meta: api.UploadSubject{ + Filename: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP.mkv", + ReleaseName: "Example.Show.S01E01.Someone.1080p.WEB-DL.x265-GRP", + EpisodeTitle: "One", + Tag: "GRP", + }, + expected: "Example.Show.S01E01.Someone.1080p.WEB-DL.x265-GRP", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := resolveUploadName(tc.meta) + if result != tc.expected { + t.Errorf("resolveUploadName() = %q; expected %q", result, tc.expected) + } + }) + } +} + +func TestResolveOrigin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + meta api.UploadSubject + expected string + }{ + { + name: "scene metadata", + meta: api.UploadSubject{Scene: true}, + expected: "Scene", + }, + { + name: "scene name metadata", + meta: api.UploadSubject{SceneName: "Example.Show.S01E01.1080p.WEB-DL.x264-GRP"}, + expected: "Scene", + }, + { + name: "non-scene metadata", + meta: api.UploadSubject{ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x264-GRP"}, + expected: "P2P", + }, + { + name: "mixed season pack groups", + meta: api.UploadSubject{ + TVPack: true, + FileList: []string{ + `C:\media\Example.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv`, + `C:\media\Example.Show.S01E02.1080p.WEB-DL.x264-ALT.mkv`, + }, + }, + expected: "Mixed", + }, + { + name: "same season pack groups", + meta: api.UploadSubject{ + TVPack: true, + FileList: []string{ + `C:\media\Example.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv`, + `C:\media\Example.Show.S01E02.1080p.WEB-DL.x264-GRP.mkv`, + }, + }, + expected: "P2P", + }, + { + name: "mixed file groups ignored outside season packs", + meta: api.UploadSubject{ + FileList: []string{ + `C:\media\Example.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv`, + `C:\media\Example.Show.S01E02.1080p.WEB-DL.x264-ALT.mkv`, + }, + }, + expected: "P2P", + }, + { + name: "none origin for no group tag", + meta: api.UploadSubject{ + Release: api.ReleaseInfo{Group: "nogrp"}, + }, + expected: "None", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resolveOrigin(tc.meta); got != tc.expected { + t.Fatalf("expected origin %q, got %q", tc.expected, got) + } + }) + } +} + +func TestResolveBTNUploadFilesSceneNFO(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + torrentPath := filepath.Join(dir, "upload.torrent") + if err := os.WriteFile(torrentPath, []byte("d8:announce13:https://x.ee"), 0o600); err != nil { + t.Fatalf("write torrent fixture: %v", err) + } + nfoPath := filepath.Join(dir, "scene.nfo") + if err := os.WriteFile(nfoPath, []byte("scene nfo"), 0o600); err != nil { + t.Fatalf("write nfo fixture: %v", err) + } + + tests := []struct { + name string + meta api.UploadSubject + wantFields []string + }{ + { + name: "scene release attaches nfo", + meta: api.UploadSubject{Scene: true, SceneNFOPath: nfoPath}, + wantFields: []string{"file_input", "nfo"}, + }, + { + name: "scene name attaches nfo", + meta: api.UploadSubject{SceneName: "Example.Show.S01E01.1080p.WEB-DL.x264-GRP", SceneNFOPath: nfoPath}, + wantFields: []string{"file_input", "nfo"}, + }, + { + name: "non-scene ignores nfo path", + meta: api.UploadSubject{SceneNFOPath: nfoPath}, + wantFields: []string{"file_input"}, + }, + { + name: "scene release skips missing nfo path", + meta: api.UploadSubject{Scene: true, SceneNFOPath: filepath.Join(dir, "missing.nfo")}, + wantFields: []string{"file_input"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + files := resolveBTNUploadFiles(tc.meta, torrentPath) + gotFields := make([]string, 0, len(files)) + for _, file := range files { + gotFields = append(gotFields, file.FieldName) + } + if strings.Join(gotFields, ",") != strings.Join(tc.wantFields, ",") { + t.Fatalf("expected fields %#v, got %#v", tc.wantFields, gotFields) + } + + dryRunFiles := resolveBTNDryRunFiles(tc.meta, torrentPath) + gotDryRunFields := make([]string, 0, len(dryRunFiles)) + for _, file := range dryRunFiles { + gotDryRunFields = append(gotDryRunFields, file.Field) + } + if strings.Join(gotDryRunFields, ",") != strings.Join(tc.wantFields, ",") { + t.Fatalf("expected dry-run fields %#v, got %#v", tc.wantFields, gotDryRunFields) + } + }) + } +} + +func TestResolveCountryIDUsesExactAliases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + meta api.UploadSubject + expected string + }{ + { + name: "TVDB alpha3 exact", + meta: api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{OriginalCountry: "usa"}, + }}, + expected: "2", + }, + { + name: "TMDB alpha2 exact", + meta: api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{OriginCountry: []string{"GB"}}, + }}, + expected: "12", + }, + { + name: "IMDB compound alias", + meta: api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Country: "Trinidad and Tobago"}, + }}, + expected: "75", + }, + { + name: "punctuation normalized exact alias", + meta: api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Country: "Bosnia & Herzegovina"}, + }}, + expected: "64", + }, + { + name: "ambiguous partial name rejected", + meta: api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Country: "Korea"}, + }}, + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := resolveCountryID(tc.meta); got != tc.expected { + t.Fatalf("expected country id %q, got %q", tc.expected, got) + } + }) + } +} + +func TestResolveCountryIDSupportsBTNCountryOptions(t *testing.T) { + t.Parallel() + + countries := map[string]string{ + "Afghanistan": "51", + "Albania": "62", + "Algeria": "32", + "Andorra": "65", + "Angola": "33", + "Antigua Barbuda": "86", + "Arab League": "107", + "Argentina": "19", + "Australia": "20", + "Austria": "34", + "Bahamas": "79", + "Bangladesh": "83", + "Barbados": "82", + "Belgium": "16", + "Belize": "31", + "Bosnia Herzegovina": "64", + "Brazil": "18", + "Brunei": "113", + "Bulgaria": "100", + "Burkina Faso": "57", + "Cambodia": "81", + "Canada": "5", + "Chile": "48", + "China": "8", + "Colombia": "95", + "Congo": "50", + "Costa Rica": "98", + "Croatia": "93", + "Cuba": "49", + "Czech Republic": "43", + "Denmark": "10", + "Dominican Republic": "38", + "Ecuador": "78", + "Egypt": "99", + "Estonia": "94", + "Fiji": "102", + "Finland": "4", + "France": "6", + "Germany": "7", + "Greece": "39", + "Guatemala": "40", + "Honduras": "76", + "Hong Kong": "30", + "Hungary": "71", + "Iceland": "59", + "India": "67", + "Indonesia": "111", + "Iran": "106", + "Ireland": "13", + "Isle de Muerte": "101", + "Israel": "41", + "Italy": "9", + "Jamaica": "28", + "Japan": "17", + "Kiribati": "55", + "Kuwait": "104", + "Kyrgyzstan": "77", + "Laos": "84", + "Latvia": "97", + "Lebanon": "96", + "Lithuania": "66", + "Luxembourg": "29", + "Macedonia": "103", + "Malaysia": "37", + "Mexico": "24", + "Nauru": "60", + "Netherlands": "15", + "Netherlands Antilles": "68", + "New Zealand": "21", + "Nigeria": "58", + "North Korea": "92", + "Norway": "11", + "Pakistan": "42", + "Paraguay": "87", + "Peru": "80", + "Philippines": "56", + "Poland": "14", + "Portugal": "23", + "Puerto Rico": "47", + "Romania": "72", + "Russia": "3", + "Saudi Arabia": "108", + "Scotland": "109", + "Senegal": "90", + "Serbia": "44", + "Seychelles": "45", + "Singapore": "25", + "Slovakia": "110", + "Slovenia": "61", + "South Africa": "26", + "South Korea": "27", + "Spain": "22", + "Sri Lanka": "105", + "Sweden": "1", + "Switzerland": "54", + "Taiwan": "46", + "Thailand": "89", + "Togo": "91", + "Trinidad & Tobago": "75", + "Turkey": "52", + "Turkmenistan": "63", + "Ukraine": "69", + "Union of Soviet Socialist Repu": "88", + "United Kingdom": "12", + "United States of America": "2", + "Uruguay": "85", + "Uzbekistan": "53", + "Vanuatu": "73", + "Venezuela": "70", + "Vietnam": "74", + "Wales": "112", + "Western Samoa": "36", + "Yugoslavia": "35", + } + + for name, expected := range countries { + meta := api.UploadSubject{ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Country: name}, + }} + if got := resolveCountryID(meta); got != expected { + t.Fatalf("expected BTN country %q to resolve to %q, got %q", name, expected, got) + } + } +} + +func TestValidateBTNAPIDownloadURLAllowsOnlySameOriginPrivateFallback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + if err := validateBTNAPIDownloadURL(ctx, "http://127.0.0.1:1/rpc", "http://127.0.0.1:1/mock-download"); err != nil { + t.Fatalf("expected same-origin private download URL to be allowed: %v", err) + } + if err := validateBTNAPIDownloadURL(ctx, "http://127.0.0.1:1/rpc", "http://127.0.0.1:2/mock-download"); err == nil { + t.Fatalf("expected cross-origin private download URL to be rejected") + } + if err := validateBTNAPIDownloadURL(ctx, "ftp://127.0.0.1/rpc", "ftp://127.0.0.1/mock-download"); err == nil { + t.Fatalf("expected unsupported same-origin scheme to be rejected") + } +} + +func TestBTNAPIDownloadOriginRejectsSameHostPrivateRebind(t *testing.T) { + ctx := context.Background() + lookupCalls := 0 + lookup := func(context.Context, string) ([]net.IPAddr, error) { + lookupCalls++ + if lookupCalls == 1 { + return []net.IPAddr{{IP: net.IPv4(93, 184, 216, 34)}}, nil + } + return []net.IPAddr{{IP: net.IPv4(10, 0, 0, 7)}}, nil + } + + origin, err := newBTNAPIDownloadOriginWithLookup(ctx, "https://api.example.test/rpc", lookup) + if err != nil { + t.Fatalf("pin BTN API origin: %v", err) + } + if err := origin.validateDownloadURL(ctx, "https://api.example.test/download"); err == nil { + t.Fatalf("expected same-host private rebind to be rejected") + } +} + +func TestBTNDropdownMappingsPreferMetadataThenAutofill(t *testing.T) { + t.Parallel() + + autofillFields := map[string]string{ + "format": "MP4", + "bitrate": "H.264", + "media": "HDTV", + "resolution": "2160p", + } + metadata := api.UploadSubject{ + Container: "mkv", + VideoEncode: "x265", + Source: "WEB-DL", + Type: "WEBDL", + Release: api.ReleaseInfo{Resolution: "1080p"}, + } + if got := mapContainer(metadata, autofillFields); got != "MKV" { + t.Fatalf("expected metadata format, got %q", got) + } + if got := mapCodec(metadata, autofillFields); got != "H.265" { + t.Fatalf("expected metadata bitrate, got %q", got) + } + if got := mapSource(metadata, autofillFields); got != "WEB-DL" { + t.Fatalf("expected metadata media, got %q", got) + } + if got := mapResolution(metadata, autofillFields); got != "1080p" { + t.Fatalf("expected metadata resolution, got %q", got) + } + + invalidMetadata := api.UploadSubject{ + Container: "unknown-container", + VideoEncode: "unknown-codec", + Source: "unknown-source", + Type: "unknown-type", + Release: api.ReleaseInfo{Resolution: "unknown-resolution"}, + } + if got := mapContainer(invalidMetadata, autofillFields); got != "MP4" { + t.Fatalf("expected autofill format, got %q", got) + } + if got := mapCodec(invalidMetadata, autofillFields); got != "H.264" { + t.Fatalf("expected autofill bitrate, got %q", got) + } + if got := mapSource(invalidMetadata, autofillFields); got != "HDTV" { + t.Fatalf("expected autofill media, got %q", got) + } + if got := mapResolution(invalidMetadata, autofillFields); got != "2160p" { + t.Fatalf("expected autofill resolution, got %q", got) + } + if got := mapResolution(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "Portable Device"}}, autofillFields); got != "Portable Device" { + t.Fatalf("expected metadata portable-device resolution, got %q", got) + } + if got := mapResolution(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "Mixed"}}, autofillFields); got != "Mixed" { + t.Fatalf("expected metadata mixed resolution, got %q", got) + } +} + +func TestResolveBTNTagsUsesAutofillThenMetadataGenres(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{Genres: "Drama, Science-Fiction, Mystery, Unlisted"}, + IMDB: &api.IMDBMetadata{Genres: "Comedy, Crime"}, + }, + } + if got := resolveBTNTags(meta, map[string]string{"tags": "Drama, Comedy"}); got != "Drama, Comedy" { + t.Fatalf("expected autofill tags, got %q", got) + } + if got := resolveBTNTags(meta, map[string]string{}); got != "Drama, Mystery, Science Fiction" { + t.Fatalf("expected TVDB fallback tags, got %q", got) + } + + meta.ProviderMetadata.TVDB.Genres = "" + if got := resolveBTNTags(meta, map[string]string{}); got != "Comedy, Crime" { + t.Fatalf("expected IMDb fallback tags, got %q", got) + } + if got := resolveBTNTags(api.UploadSubject{}, map[string]string{}); got != "" { + t.Fatalf("expected empty tags without autofill or provider genres, got %q", got) + } +} + +func TestResolveBTNImageUsesAutofillThenMetadataPosters(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{Poster: "https://img.example/tvdb.jpg"}, + IMDB: &api.IMDBMetadata{Cover: "https://img.example/imdb.jpg"}, + TVmaze: &api.TVmazeMetadata{Poster: "https://img.example/tvmaze.jpg", PosterMedium: "https://img.example/tvmaze-medium.jpg"}, + TMDB: &api.TMDBMetadata{Poster: "https://img.example/tmdb.jpg"}, + }, + } + if got := resolveBTNImage(meta, map[string]string{"image": "https://img.example/autofill.jpg"}); got != "https://img.example/autofill.jpg" { + t.Fatalf("expected autofill poster, got %q", got) + } + if got := resolveBTNImage(meta, map[string]string{}); got != "https://img.example/tvdb.jpg" { + t.Fatalf("expected TVDB poster, got %q", got) + } + + meta.ProviderMetadata.TVDB = nil + if got := resolveBTNImage(meta, map[string]string{}); got != "https://img.example/imdb.jpg" { + t.Fatalf("expected IMDb poster, got %q", got) + } + meta.ProviderMetadata.IMDB = nil + if got := resolveBTNImage(meta, map[string]string{}); got != "https://img.example/tvmaze.jpg" { + t.Fatalf("expected TVmaze poster, got %q", got) + } + meta.ProviderMetadata.TVmaze.Poster = "" + if got := resolveBTNImage(meta, map[string]string{}); got != "https://img.example/tvmaze-medium.jpg" { + t.Fatalf("expected TVmaze medium poster, got %q", got) + } + meta.ProviderMetadata.TVmaze = nil + if got := resolveBTNImage(meta, map[string]string{}); got != "https://img.example/tmdb.jpg" { + t.Fatalf("expected TMDB poster, got %q", got) + } +} + +func TestDecodeBTNAPIJSONRejectsDuplicateKeysAndLargeBodies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload []byte + expectedErr string + }{ + { + name: "root duplicate", + payload: []byte(`{"result":{},"result":{}}`), + expectedErr: `duplicate JSON object key "result"`, + }, + { + name: "nested duplicate", + payload: []byte(`{"result":{"DownloadURL":"https://example.test/one","DownloadURL":"https://example.test/two"}}`), + expectedErr: `duplicate JSON object key "DownloadURL" at "result"`, + }, + { + name: "oversized", + payload: bytes.Repeat([]byte(" "), btnAPIJSONMaxBytes+1), + expectedErr: "response body exceeds", + }, + { + name: "multiple values", + payload: []byte(`{} {}`), + expectedErr: "multiple JSON values", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var decoded struct { + Result map[string]any `json:"result"` + } + err := decodeBTNAPIJSON(bytes.NewReader(tc.payload), &decoded) + if err == nil || !strings.Contains(err.Error(), tc.expectedErr) { + t.Fatalf("expected error containing %q", tc.expectedErr) + } + }) + } + + var decoded struct { + Result struct { + DownloadURL string `json:"DownloadURL"` + } `json:"result"` + } + if err := decodeBTNAPIJSON(strings.NewReader(`{"result":{"DownloadURL":"https://example.test/download"}}`), &decoded); err != nil { + t.Fatalf("decode valid BTN API JSON: %v", err) + } + if decoded.Result.DownloadURL != "https://example.test/download" { + t.Fatalf("unexpected decoded DownloadURL") + } +} + +func TestBTNUploadPayloadUsesCanonicalSeasonEpisodeOnly(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{Season: 4, Episode: 9}, + } + + if got := resolveUploadType(meta); got != "Season" { + t.Fatalf("expected upload type Season without canonical episode, got %q", got) + } + desc := buildAlbumDesc(meta, nil) + for _, value := range []string{"[b]Season:[/b] 0", "[b]Episode:[/b] 0"} { + if !strings.Contains(desc, value) { + t.Fatalf("expected album description to contain %q, got %q", value, desc) + } + } + if strings.Contains(desc, "[b]Season:[/b] 4") || strings.Contains(desc, "[b]Episode:[/b] 9") { + t.Fatalf("album description used parsed fallback values: %q", desc) + } + if got := btnTVPayloadMetadataMessage(meta); got != "canonical TV season/episode missing; BTN upload requires TVDB or metadata season/episode ints and ignores parsed season/episode fallback; refresh metadata or correct canonical season/episode before upload" { + t.Fatalf("unexpected metadata message %q", got) + } +} + +func TestBTNUploadTypeUsesCanonicalEpisode(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 4, + EpisodeInt: 9, + Release: api.ReleaseInfo{Season: 1, Episode: 2}, + } + + if got := resolveUploadType(meta); got != "Episode" { + t.Fatalf("expected upload type Episode, got %q", got) + } + if got := btnTVPayloadMetadataMessage(meta); got != "" { + t.Fatalf("unexpected metadata message %q", got) + } +} + +func TestResolveBTNTVSeasonEpisodePrefersTVDBThenIMDBThenMetadata(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + SeasonInt: 2, + EpisodeInt: 7, + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{EpisodeSeason: 4, EpisodeNumber: 12}, + }, + } + + season, episode := resolveBTNTVSeasonEpisode(meta) + if season != 4 || episode != 12 { + t.Fatalf("expected TVDB season/episode 4/12, got %d/%d", season, episode) + } + if got := btnTVPayloadMetadataMessage(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{EpisodeSeason: 4, EpisodeNumber: 12}, + }, + }); got != "" { + t.Fatalf("expected TVDB season/episode to satisfy BTN metadata check, got %q", got) + } + + meta.ProviderMetadata.TVDB = &api.TVDBMetadata{} + meta.ProviderMetadata.IMDB = &api.IMDBMetadata{Episodes: []api.IMDBEpisode{{Season: 2, EpisodeText: "E07"}}} + season, episode = resolveBTNTVSeasonEpisode(meta) + if season != 2 || episode != 7 { + t.Fatalf("expected IMDb season/episode 2/7, got %d/%d", season, episode) + } + + if got := btnTVPayloadMetadataMessage(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Episodes: []api.IMDBEpisode{{Season: 3, EpisodeText: "Episode 8"}}}, + }, + }); got != "" { + t.Fatalf("expected sole IMDb episode to satisfy BTN metadata check, got %q", got) + } + + meta.ProviderMetadata.TVDB = nil + meta.ProviderMetadata.IMDB = nil + season, episode = resolveBTNTVSeasonEpisode(meta) + if season != 2 || episode != 7 { + t.Fatalf("expected metadata season/episode 2/7, got %d/%d", season, episode) + } +} + +func TestBuildAlbumDescFallsBackToIMDBEpisodeMetadata(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 2, + EpisodeInt: 7, + EpisodeTitle: "Metadata Episode", + EpisodeOverview: "Metadata overview", + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{}, + IMDB: &api.IMDBMetadata{ + Plot: "IMDb overview", + Episodes: []api.IMDBEpisode{{ + Title: "IMDb Episode", + Season: 2, + EpisodeText: "7", + ReleaseDate: api.IMDBReleaseDate{ + Year: 2026, + Month: 3, + Day: 4, + }, + }}, + }, + }, + } + + if desc := buildAlbumDesc(meta, map[string]string{"album_desc": "Autofill overview"}); desc != "Autofill overview" { + t.Fatalf("expected BTN autofill album_desc to win, got %q", desc) + } + + desc := buildAlbumDesc(meta, nil) + for _, value := range []string{"[b]Episode Name:[/b] IMDb Episode", "[b]Season:[/b] 2", "[b]Episode:[/b] 7", "[b]Aired:[/b] 2026-03-04", "[b]Episode overview:[/b]\nIMDb overview"} { + if !strings.Contains(desc, value) { + t.Fatalf("expected album_desc to contain %q, got %q", value, desc) + } + } +} + +func TestBuildAlbumDescUsesSingleTVDBEpisodeBlock(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 2, + EpisodeInt: 1, + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{ + Episodes: []api.TVDBEpisodeMetadata{ + { + SeasonNumber: 2, + EpisodeNumber: 1, + EpisodeName: "Example Episode One", + EpisodeOverview: "Example overview one.", + EpisodeAired: "2026-04-23", + EpisodeImage: "https://img.example/tvdb-episode-1.jpg", + }, + { + SeasonNumber: 2, + EpisodeNumber: 2, + EpisodeName: "Example Episode Two", + EpisodeOverview: "Example overview two.", + EpisodeAired: "2026-04-30", + EpisodeImage: "https://img.example/tvdb-episode-2.jpg", + }, + }, + }, + }, + } + + desc := buildAlbumDesc(meta, nil) + want := "[b]Episode Name:[/b] Example Episode One\n" + + "[b]Season:[/b] 2\n" + + "[b]Episode:[/b] 1\n" + + "[b]Aired:[/b] 2026-04-23\n\n" + + "[b]Episode overview:[/b]\n" + + "Example overview one.\n\n" + + "[b]Episode image:[/b]\n" + + "[img=https://img.example/tvdb-episode-1.jpg]" + if desc != want { + t.Fatalf("unexpected single episode album_desc:\n%s", desc) + } + if strings.Contains(desc, "Example Episode Two") { + t.Fatalf("single episode album_desc included another episode: %q", desc) + } +} + +func TestBuildAlbumDescUsesTVDBSeasonEpisodeBlocks(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + SeasonInt: 2, + TVPack: true, + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{ + Episodes: []api.TVDBEpisodeMetadata{ + { + SeasonNumber: 2, + EpisodeNumber: 2, + EpisodeName: "Example Episode Two", + EpisodeOverview: "Example overview two.", + EpisodeAired: "2026-04-30", + EpisodeImage: "https://img.example/tvdb-episode-2.jpg", + }, + { + SeasonNumber: 1, + EpisodeNumber: 1, + EpisodeName: "Wrong Season", + EpisodeOverview: "Wrong season overview.", + EpisodeAired: "2025-04-23", + }, + { + SeasonNumber: 2, + EpisodeNumber: 1, + EpisodeName: "Example Episode One", + EpisodeOverview: "Example overview one.", + EpisodeAired: "2026-04-23", + EpisodeImage: "https://img.example/tvdb-episode-1.jpg", + }, + }, + }, + }, + } + + desc := buildAlbumDesc(meta, nil) + if strings.Contains(desc, "Wrong Season") { + t.Fatalf("season pack album_desc included another season: %q", desc) + } + first := strings.Index(desc, "Example Episode One") + second := strings.Index(desc, "Example Episode Two") + if first < 0 || second < 0 || first > second { + t.Fatalf("season pack album_desc did not include sorted season episodes: %q", desc) + } + for _, value := range []string{ + "[b]Episode Name:[/b] Example Episode One", + "[b]Episode image:[/b]\n[img=https://img.example/tvdb-episode-1.jpg]", + "[b]Episode Name:[/b] Example Episode Two", + "[b]Episode image:[/b]\n[img=https://img.example/tvdb-episode-2.jpg]", + } { + if !strings.Contains(desc, value) { + t.Fatalf("expected season pack album_desc to contain %q, got %q", value, desc) + } + } +} + +func TestResolveBTNOriginalLanguageUsesTVDBThenIMDB(t *testing.T) { + t.Parallel() + + meta := api.UploadSubject{ + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{OriginalLanguage: "jpn"}, + IMDB: &api.IMDBMetadata{OriginalLanguage: "fra"}, + }, + } + if got := resolveBTNOriginalLanguage(meta); got != "jpn" { + t.Fatalf("expected TVDB language, got %q", got) + } + meta.ProviderMetadata.TVDB.OriginalLanguage = "" + if got := resolveBTNOriginalLanguage(meta); got != "fra" { + t.Fatalf("expected IMDb fallback language, got %q", got) + } + for _, value := range []string{"en", "eng", "English"} { + if !isBTNEnglishLanguage(value) { + t.Fatalf("expected %q to be treated as English", value) + } + } +} diff --git a/internal/trackers/impl/standalone/btn/dupe.go b/internal/trackers/impl/standalone/btn/dupe.go new file mode 100644 index 00000000..f88c1eb1 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/dupe.go @@ -0,0 +1,193 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: "https://api.broadcasthe.net/", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + token := strings.TrimSpace(config.ResolveBTNAPIToken(s.cfg)) + if token == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + if !isTV(meta) { + return dupe.NotRun(dupe.NotRunUnsupportedContent, "BTN only supports TV dupe search", nil) + } + filter := make(map[string]any) + switch { + case trackerID(meta) != "": + filter["id"] = trackerID(meta) + case meta.Identity.IMDBID != 0: + filter["imdb"] = fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + case meta.Identity.TVDBID != 0: + filter["tvdb"] = meta.Identity.TVDBID + case searchTitle(meta) != "": + filter["searchstr"] = searchTitle(meta) + default: + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing btn/imdb/tvdb id and title for BTN dupe search", nil) + } + payload := map[string]any{ + "jsonrpc": "2.0", + "id": "upbrr-btn-search", + "method": "getTorrentsSearch", + "params": []any{token, filter, 50}, + } + raw, err := json.Marshal(payload) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "BTN request failed", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(raw)) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "BTN request failed", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "BTN request failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "BTN search failed", nil) + } + var response map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&response); err != nil { + return dupe.Failed(dupe.FailureResponseParse, "BTN search failed", err) + } + if errorPayload, ok := response["error"].(map[string]any); ok && len(errorPayload) > 0 { + return dupe.Failed(dupe.FailureResponseStatus, "BTN api rejected search", nil) + } + result, _ := response["result"].(map[string]any) + torrents, _ := result["torrents"].(map[string]any) + entries := make([]api.DupeEntry, 0, len(torrents)) + for id, rawTorrent := range torrents { + torrent, ok := rawTorrent.(map[string]any) + if !ok { + continue + } + entry := api.DupeEntry{ + Name: releaseName(id, torrent), + ID: strings.TrimSpace(id), + Link: torrentLink(id, torrent), + Res: btnString(first(torrent, "Resolution", "resolution")), + Type: btnString(first(torrent, "Source", "source", "Type", "type")), + } + if size := btnInt(first(torrent, "Size", "size")); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + entry.Flags = flags(torrent) + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +func isTV(meta api.DuplicateSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func trackerID(meta api.DuplicateSubject) string { + for key, value := range meta.TrackerIDs { + if strings.EqualFold(strings.TrimSpace(key), "BTN") { + return strings.TrimSpace(value) + } + } + return "" +} + +func searchTitle(meta api.DuplicateSubject) string { + candidates := []string{strings.TrimSpace(meta.Release.Title)} + if meta.ProviderMetadata.TVDB != nil { + candidates = append(candidates, strings.TrimSpace(meta.ProviderMetadata.TVDB.Name), strings.TrimSpace(meta.ProviderMetadata.TVDB.NameEnglish)) + } + if meta.ProviderMetadata.TVmaze != nil { + candidates = append(candidates, strings.TrimSpace(meta.ProviderMetadata.TVmaze.Name)) + } + candidates = append(candidates, strings.TrimSpace(meta.Filename), strings.TrimSpace(meta.ReleaseName)) + for _, candidate := range candidates { + if candidate != "" { + return candidate + } + } + return "" +} + +func releaseName(id string, torrent map[string]any) string { + for _, candidate := range []string{btnString(first(torrent, "ReleaseName", "releaseName")), btnString(first(torrent, "SceneName", "Name", "name")), btnString(first(torrent, "Series", "series")), strings.TrimSpace(id)} { + if candidate != "" { + return candidate + } + } + return "" +} + +func torrentLink(id string, torrent map[string]any) string { + groupID := btnString(first(torrent, "GroupID", "groupId")) + if groupID == "" || strings.TrimSpace(id) == "" { + return "" + } + return "https://broadcasthe.net/torrents.php?id=" + groupID + "&torrentid=" + strings.TrimSpace(id) +} + +func flags(torrent map[string]any) []string { + out := make([]string, 0, 2) + for _, value := range []string{btnString(first(torrent, "HDR", "hdr")), btnString(first(torrent, "DolbyVision", "dolbyVision", "DV", "dv"))} { + upper := strings.ToUpper(strings.TrimSpace(value)) + switch upper { + case "", "0", "FALSE", "NO", "1", "TRUE", "YES": + continue + } + out = append(out, upper) + } + return out +} + +func first(values map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := values[key]; ok { + return value + } + } + return nil +} + +func btnString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} diff --git a/internal/trackers/impl/standalone/btn/dupe_test.go b/internal/trackers/impl/standalone/btn/dupe_test.go new file mode 100644 index 00000000..e1b066e8 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/dupe_test.go @@ -0,0 +1,321 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +func btnTestInt(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} + +func btnTestString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func TestBTNHandlerSkipsWithoutAPIKey(t *testing.T) { + t.Parallel() + + handler := dupe.NewAdapter(New(), "BTN", config.Config{}, http.DefaultClient, nil) + result := handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + }, + }) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunMissingCredentials || result.SafeMessage() != "missing api_key for tracker" { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } +} + +func TestBTNHandlerSkipsNonTV(t *testing.T) { + t.Parallel() + + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), http.DefaultClient, nil) + result := handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "MOVIE", + }, + }) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunUnsupportedContent || result.SafeMessage() != "BTN only supports TV dupe search" { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } +} + +func TestBTNHandlerUsesTrackerIDFirst(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"result":{"torrents":{}}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + _, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + TrackerIDs: map[string]string{"btn": "1234"}, + Identity: api.ExternalIdentity{ + Category: "TV", + IMDBID: 7654321, + TVDBID: 8899, + }, + Release: api.ReleaseInfo{Title: "Ignored"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %v", notes) + } + filter := payloads.lastFilter(t) + assertBTNFilterValue(t, filter, "id", "1234") + if _, ok := filter["imdb"]; ok { + t.Fatalf("did not expect imdb when btn id is present: %#v", filter) + } + if _, ok := filter["tvdb"]; ok { + t.Fatalf("did not expect tvdb when btn id is present: %#v", filter) + } + if _, ok := filter["searchstr"]; ok { + t.Fatalf("did not expect searchstr when btn id is present: %#v", filter) + } +} + +func TestBTNHandlerFallsBackToIMDb(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"result":{"torrents":{}}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + _, _, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + IMDBID: 1234567, + }, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + filter := payloads.lastFilter(t) + assertBTNFilterValue(t, filter, "imdb", "tt1234567") +} + +func TestBTNHandlerFallsBackToTVDB(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"result":{"torrents":{}}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + _, _, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + TVDBID: 998877, + }, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + filter := payloads.lastFilter(t) + if got := btnTestInt(filter["tvdb"]); got != 998877 { + t.Fatalf("expected tvdb 998877, got %#v", filter["tvdb"]) + } +} + +func TestBTNHandlerFallsBackToTitleSearch(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"result":{"torrents":{}}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + _, _, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + }, + Release: api.ReleaseInfo{Title: "Example Show"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + filter := payloads.lastFilter(t) + assertBTNFilterValue(t, filter, "searchstr", "Example Show") +} + +func TestBTNHandlerNormalizesEntries(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"result":{"torrents":{"777":{"GroupID":"333","ReleaseName":"Example.Show.S01E01.1080p.WEB-DL.HDR.DV","Size":12345,"Resolution":"1080p","Source":"WEB-DL","HDR":"HDR10","DolbyVision":"DV"}}}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + }, + Release: api.ReleaseInfo{Title: "Example Show"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + entry := entries[0] + if entry.Name != "Example.Show.S01E01.1080p.WEB-DL.HDR.DV" { + t.Fatalf("unexpected name: %#v", entry) + } + if entry.ID != "777" { + t.Fatalf("unexpected id: %#v", entry) + } + if entry.Link != "https://broadcasthe.net/torrents.php?id=333&torrentid=777" { + t.Fatalf("unexpected link: %#v", entry) + } + if !entry.SizeKnown || entry.SizeBytes != 12345 { + t.Fatalf("unexpected size fields: %#v", entry) + } + if entry.Res != "1080p" { + t.Fatalf("unexpected resolution: %#v", entry) + } + if entry.Type != "WEB-DL" { + t.Fatalf("unexpected type: %#v", entry) + } + if len(entry.Flags) != 2 || entry.Flags[0] != "HDR10" || entry.Flags[1] != "DV" { + t.Fatalf("unexpected flags: %#v", entry.Flags) + } +} + +func TestBTNHandlerAPIErrorReturnsNoDupes(t *testing.T) { + t.Parallel() + + payloads := captureBTNPayloads(t, `{"error":{"message":"bad request"}}`) + handler := dupe.NewAdapter(New(), "BTN", configWithBTNAPIKey(), payloads.client, nil) + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + SourcePath: "x", + Identity: api.ExternalIdentity{ + Category: "TV", + }, + Release: api.ReleaseInfo{Title: "Example Show"}, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %v", notes) + } +} + +type btnPayloadCapture struct { + client *http.Client + payload map[string]any + mu sync.Mutex +} + +func captureBTNPayloads(t *testing.T, response string) *btnPayloadCapture { + t.Helper() + + capture := &btnPayloadCapture{} + capture.client = &http.Client{ + Transport: btnRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read BTN payload request body: %w", err) + } + _ = req.Body.Close() + + capture.mu.Lock() + defer capture.mu.Unlock() + if err := json.Unmarshal(body, &capture.payload); err != nil { + return nil, fmt.Errorf("unmarshal BTN payload request body: %w", err) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(response)), + Request: req, + }, nil + }), + } + return capture +} + +func (c *btnPayloadCapture) lastFilter(t *testing.T) map[string]any { + t.Helper() + + c.mu.Lock() + defer c.mu.Unlock() + + params, ok := c.payload["params"].([]any) + if !ok || len(params) < 2 { + t.Fatalf("expected JSON-RPC params, got %#v", c.payload) + } + filter, ok := params[1].(map[string]any) + if !ok { + t.Fatalf("expected filter map, got %#v", params[1]) + } + return filter +} + +func configWithBTNAPIKey() config.Config { + return config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "BTN": {APIKey: strings.Repeat("x", 30)}, + }, + }, + } +} + +func assertBTNFilterValue(t *testing.T, filter map[string]any, key string, want string) { + t.Helper() + if got := btnTestString(filter[key]); got != want { + t.Fatalf("expected %s=%q, got %#v", key, want, filter) + } +} + +type btnRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn btnRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/internal/trackers/impl/standalone/btn/profile.go b/internal/trackers/impl/standalone/btn/profile.go new file mode 100644 index 00000000..630f6223 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/profile.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "context" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns BTN identity, preparation, dupe, auth, bans, and policies. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "BTN", + BaseURL: btnDefaultBaseURL, + DescriptionGroup: "btn", + UploadContentMode: trackers.UploadContentModeNone, + PrepareUpload: func(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, req, btnDefaultBaseURL) + }, + NewDuplicateAdapter: newDuplicateAdapter, + BannedGroups: bannedGroups(), + DataPolicy: &trackers.DataLookupPolicy{DeferWhenCollectingImages: true}, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeTV, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDB, trackers.MetadataFieldTVDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "BTN", RequireAnnounce: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://broadcasthe.net", "https://backup.landof.tv", "https://landof.tv", "landof.tv/"}, + CommentURLPatterns: []string{"https://broadcasthe.net", "https://backup.landof.tv", "https://landof.tv"}, + DetailIDPattern: "id=(\\d+)", + InferMatchFromResolvedID: true, + }, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "BTN", + DisplayName: "BTN", + AuthKind: "api_key_cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + SupportsTOTP: true, + SupportsManual2FA: true, + RequiresAPIKey: true, + Notes: []string{"API key is required for torrent resolution; cookies/login cover upload auth."}, + }, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, login, btnDefaultBaseURL) + }, + AuthPolicy: &trackers.AuthPolicy{ //nolint:gosec // Operator messages name API keys; no credential is embedded. + ResolveAPIKey: func(cfg config.Config, _ config.TrackerConfig) string { return config.ResolveBTNAPIToken(cfg) }, + APIKeyRequiresUploadSession: true, + MissingAPIKeyMessage: "API key is required for torrent resolution; imported cookies or login credentials cover upload auth", + UploadSessionMissingMessage: "API key covers torrent resolution; imported cookies or login credentials required for upload auth", + }, + } +} + +// Definition extends the shared standalone definition with BTN data and claim lookup. +type Definition struct { + *standalone.Definition + baseURL string +} + +// New returns a fresh BTN definition from its tracker-local profile. +func New() *Definition { + return &Definition{Definition: standalone.MustNew(Profile()), baseURL: btnDefaultBaseURL} +} diff --git a/internal/trackers/impl/standalone/btn/upload.go b/internal/trackers/impl/standalone/btn/upload.go new file mode 100644 index 00000000..365f2525 --- /dev/null +++ b/internal/trackers/impl/standalone/btn/upload.go @@ -0,0 +1,3078 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package btn + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. + "encoding/base32" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/netip" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/moistari/rls" + xhtml "golang.org/x/net/html" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + btnDefaultBaseURL = "https://backup.landof.tv" + btnUploadPath = "/upload.php" + btnLoginPath = "/login.php" + btnAPIRPCURL = "https://api.broadcasthe.net/" + btnAPIJSONMaxBytes = 1024 * 1024 + btnTorrentMaxBytes = 8 * 1024 * 1024 +) + +var ( + btnInputPattern = regexp.MustCompile(`(?is)]*name=["']([^"']+)["'][^>]*value=["']([^"']*)["'][^>]*>`) + btnTextAreaPattern = regexp.MustCompile(`(?is)]*name=["']album_desc["'][^>]*>(.*?)`) + btnSelectPattern = regexp.MustCompile(`(?is)]*name=["']([^"']+)["'][^>]*>(.*?)`) + btnSuccessURLPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)(?:&torrentid=(\d+))?`) + btnHTMLURLAttrPattern = regexp.MustCompile(`(?is)\b(?:href|action)=["']([^"']+)["']`) + btnIMDBEpisodePattern = regexp.MustCompile(`(?i)(?:^|\bE|episode\s*)(\d{1,4})(?:\b|$)`) + // btnCountryMap maps normalized BTN country option labels and exact + // metadata-source country codes to BTN's country select values. + //literalpolicy:allow aliases are grouped by BTN country value + btnCountryMap = map[string]string{ + "se": "1", "swe": "1", "sweden": "1", + "us": "2", "usa": "2", "united states": "2", "united states of america": "2", + "ru": "3", "rus": "3", "russia": "3", "russian federation": "3", + "fi": "4", "fin": "4", "finland": "4", + "ca": "5", "can": "5", "canada": "5", + "fr": "6", "fra": "6", "france": "6", + "de": "7", "deu": "7", "germany": "7", + "cn": "8", "chn": "8", "china": "8", + "it": "9", "ita": "9", "italy": "9", + "dk": "10", "dnk": "10", "denmark": "10", + "no": "11", "nor": "11", "norway": "11", + "gb": "12", "uk": "12", "gbr": "12", "united kingdom": "12", + "ie": "13", "irl": "13", "ireland": "13", + "pl": "14", "pol": "14", "poland": "14", + "nl": "15", "nld": "15", "netherlands": "15", + "be": "16", "bel": "16", "belgium": "16", + "jp": "17", "jpn": "17", "japan": "17", + "br": "18", "bra": "18", "brazil": "18", + "ar": "19", "arg": "19", "argentina": "19", + "au": "20", "aus": "20", "australia": "20", + "nz": "21", "nzl": "21", "new zealand": "21", + "es": "22", "esp": "22", "spain": "22", + "pt": "23", "prt": "23", "portugal": "23", + "mx": "24", "mex": "24", "mexico": "24", + "sg": "25", "sgp": "25", "singapore": "25", + "za": "26", "zaf": "26", "south africa": "26", + "kr": "27", "kor": "27", "south korea": "27", + "jm": "28", "jam": "28", "jamaica": "28", + "lu": "29", "lux": "29", "luxembourg": "29", + "hk": "30", "hkg": "30", "hong kong": "30", + "bz": "31", "blz": "31", "belize": "31", + "dz": "32", "dza": "32", "algeria": "32", + "ao": "33", "ago": "33", "angola": "33", + "at": "34", "aut": "34", "austria": "34", + "yu": "35", "yug": "35", "yugoslavia": "35", + "ws": "36", "wsm": "36", "western samoa": "36", + "my": "37", "mys": "37", "malaysia": "37", + "do": "38", "dom": "38", "dominican republic": "38", + "gr": "39", "grc": "39", "greece": "39", + "gt": "40", "gtm": "40", "guatemala": "40", + "il": "41", "isr": "41", "israel": "41", + "pk": "42", "pak": "42", "pakistan": "42", + "cz": "43", "cze": "43", "czech republic": "43", "czechia": "43", + "rs": "44", "srb": "44", "serbia": "44", + "sc": "45", "syc": "45", "seychelles": "45", + "tw": "46", "twn": "46", "taiwan": "46", + "pr": "47", "pri": "47", "puerto rico": "47", + "cl": "48", "chl": "48", "chile": "48", + "cu": "49", "cub": "49", "cuba": "49", + "cg": "50", "cog": "50", "congo": "50", + "af": "51", "afg": "51", "afghanistan": "51", + "tr": "52", "tur": "52", "turkey": "52", + "uz": "53", "uzb": "53", "uzbekistan": "53", + "ch": "54", "che": "54", "switzerland": "54", + "ki": "55", "kir": "55", "kiribati": "55", + "ph": "56", "phl": "56", "philippines": "56", + "bf": "57", "bfa": "57", "burkina faso": "57", + "ng": "58", "nga": "58", "nigeria": "58", + "is": "59", "isl": "59", "iceland": "59", + "nr": "60", "nru": "60", "nauru": "60", + "si": "61", "svn": "61", "slovenia": "61", + "al": "62", "alb": "62", "albania": "62", + "tm": "63", "tkm": "63", "turkmenistan": "63", + "ba": "64", "bih": "64", "bosnia herzegovina": "64", "bosnia and herzegovina": "64", + "ad": "65", "and": "65", "andorra": "65", + "lt": "66", "ltu": "66", "lithuania": "66", + "in": "67", "ind": "67", "india": "67", + "an": "68", "ant": "68", "netherlands antilles": "68", + "ua": "69", "ukr": "69", "ukraine": "69", + "ve": "70", "ven": "70", "venezuela": "70", + "hu": "71", "hun": "71", "hungary": "71", + "ro": "72", "rou": "72", "romania": "72", + "vu": "73", "vut": "73", "vanuatu": "73", + "vn": "74", "vnm": "74", "vietnam": "74", + "tt": "75", "tto": "75", "trinidad": "75", "trinidad and tobago": "75", + "hn": "76", "hnd": "76", "honduras": "76", + "kg": "77", "kgz": "77", "kyrgyzstan": "77", + "ec": "78", "ecu": "78", "ecuador": "78", + "bs": "79", "bhs": "79", "bahamas": "79", + "pe": "80", "per": "80", "peru": "80", + "kh": "81", "khm": "81", "cambodia": "81", + "bb": "82", "brb": "82", "barbados": "82", + "bd": "83", "bgd": "83", "bangladesh": "83", + "la": "84", "lao": "84", "laos": "84", + "uy": "85", "ury": "85", "uruguay": "85", + "ag": "86", "atg": "86", "antigua barbuda": "86", "antigua and barbuda": "86", + "py": "87", "pry": "87", "paraguay": "87", + "su": "88", "sun": "88", "soviet": "88", "soviet union": "88", "ussr": "88", "union of soviet socialist repu": "88", + "th": "89", "tha": "89", "thailand": "89", + "sn": "90", "sen": "90", "senegal": "90", + "tg": "91", "tgo": "91", "togo": "91", + "kp": "92", "prk": "92", "north korea": "92", + "hr": "93", "hrv": "93", "croatia": "93", + "ee": "94", "est": "94", "estonia": "94", + "co": "95", "col": "95", "colombia": "95", + "lb": "96", "lbn": "96", "lebanon": "96", + "lv": "97", "lva": "97", "latvia": "97", + "cr": "98", "cri": "98", "costa rica": "98", + "eg": "99", "egy": "99", "egypt": "99", + "bg": "100", "bgr": "100", "bulgaria": "100", + "isle de muerte": "101", + "fj": "102", "fji": "102", "fiji": "102", + "mk": "103", "mkd": "103", "macedonia": "103", + "kw": "104", "kwt": "104", "kuwait": "104", + "lk": "105", "lka": "105", "sri lanka": "105", + "ir": "106", "irn": "106", "iran": "106", + "arab league": "107", + "sa": "108", "sau": "108", "saudi arabia": "108", + "scotland": "109", + "sk": "110", "svk": "110", "slovakia": "110", + "id": "111", "idn": "111", "indonesia": "111", + "wales": "112", + "bn": "113", "brn": "113", "brunei": "113", + } +) + +// ErrSubmitted2FARejected marks a BTN failure after a submitted manual 2FA code +// reached the tracker and was rejected. +var ErrSubmitted2FARejected = trackers.ErrSubmitted2FARejected + +var ( + errBTNCookiesMissing = errors.New("trackers: BTN cookies not configured") + errBTNSessionConfirmedInvalid = errors.New("trackers: BTN stored session confirmed invalid") +) + +type uploadContext struct { + baseURL string + uploadURL string + apiToken string + apiURL string + client *http.Client +} + +// btnNameNormalizationRule applies one ordered rewrite to the BTN scene name. +type btnNameNormalizationRule struct { + pattern *regexp.Regexp + replacement string +} + +// btnNameNormalizationRules normalizes release-name tokens after whitespace and +// DD+ cleanup. The order is significant: Atmos-specific compaction must happen +// before generic audio channel joins, and dot collapse must run after character +// replacement. +var btnNameNormalizationRules = []btnNameNormalizationRule{ + {pattern: regexp.MustCompile(`(?i)\.DDP\.(\d+(?:\.\d+)?)\.Atmos`), replacement: `.DDPA$1`}, + {pattern: regexp.MustCompile(`(?i)\.TrueHD\.(\d+(?:\.\d+)?)\.Atmos`), replacement: `.TrueHDA$1`}, + {pattern: regexp.MustCompile(`\.DDP\.(\d)`), replacement: `.DDP$1`}, + {pattern: regexp.MustCompile(`\.DD\.(\d)`), replacement: `.DD$1`}, + {pattern: regexp.MustCompile(`\.AC3\.(\d)`), replacement: `.AC3$1`}, + {pattern: regexp.MustCompile(`\.DTS\.(\d)`), replacement: `.DTS$1`}, + {pattern: regexp.MustCompile(`\.AAC\.(\d)`), replacement: `.AAC$1`}, + {pattern: regexp.MustCompile(`\.FLAC\.(\d)`), replacement: `.FLAC$1`}, + {pattern: regexp.MustCompile(`(?i)\.TrueHD\.(\d)`), replacement: `.TrueHD$1`}, + {pattern: regexp.MustCompile(`(?i)\.PCM\.(\d)`), replacement: `.PCM$1`}, + {pattern: regexp.MustCompile(`(?i)\.LPCM\.(\d)`), replacement: `.LPCM$1`}, + {pattern: regexp.MustCompile(`[^a-zA-Z0-9.\-]`), replacement: `.`}, + {pattern: regexp.MustCompile(`\.{2,}`), replacement: `.`}, +} + +func uploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (api.UploadSummary, error) { + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, baseURL) + }) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BTN submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (trackers.PreparedOperation, error) { + if req.Intent != trackers.PreparationIntentUpload { + preview, err := buildUploadDryRunAt(ctx, req, baseURL) + if err != nil { + return trackers.PreparedOperation{}, err + } + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if err := validateBTNRequest(req); err != nil { + return trackers.PreparedOperation{}, err + } + if message, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { + if req.Logger != nil { + req.Logger.Warnf("trackers: BTN %s", message) + } + return trackers.PreparedOperation{}, err + } + + torrentPath, err := resolveTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + + uploadCtx, err := newUploadContextAt(ctx, req, baseURL) + if err != nil { + return trackers.PreparedOperation{}, err + } + client, err := ensureBTNUploadSession(ctx, req.TrackerConfig, req.Runtime.DBPath, uploadCtx) + if err != nil { + return trackers.PreparedOperation{}, err + } + uploadCtx.client = client + + if err := checkBTNSeasonPackReservation(ctx, uploadCtx, req); err != nil { + return trackers.PreparedOperation{}, err + } + + data, err := prepareUploadData(ctx, req, uploadCtx) + if err != nil { + return trackers.PreparedOperation{}, err + } + + files := resolveBTNUploadFiles(req.Meta, torrentPath) + body, contentType, err := commonhttp.BuildMultipartPayload(data, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + trackerTorrentPath, err := resolveTrackerTorrentPath(req.Meta, req.Runtime.DBPath, "BTN") + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "BTN", + ReleaseName: resolveUploadName(req.Meta), + DescriptionGroup: "btn", + Description: data["release_desc"], + Endpoint: uploadCtx.uploadURL, + Payload: data, + Files: resolveBTNDryRunFiles(req.Meta, torrentPath), + }) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, uploadCtx, torrentPath, trackerTorrentPath, body, contentType) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + uploadCtx uploadContext, + torrentPath string, + trackerTorrentPath string, + body []byte, + contentType string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadCtx.uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BTN request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := uploadCtx.client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BTN upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + groupID, torrentID, matched := btnUploadIDsFromText(finalURL) + torrentDownloaded := false + if !matched { + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= 200 && resp.StatusCode < 400, 2048) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: BTN read upload response: %w", err) + } + intermediate, handled, err := resolveBTNUploadIntermediatePage(ctx, uploadCtx.client, uploadCtx.baseURL, finalURL, responseBody) + if handled { + if err != nil { + if req.Logger != nil { + req.Logger.Warnf("trackers: BTN intermediate upload page fallback to API search: %s", commonhttp.RedactErrorDetail(err.Error())) + } + groupID = intermediate.groupID + selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) + if err != nil { + return api.UploadSummary{}, err + } + torrentID = selectedID + if selectedGroupID != "" { + groupID = selectedGroupID + } + torrentDownloaded = true + } else { + groupID = intermediate.groupID + torrentID = intermediate.torrentID + } + } else { + groupID, torrentID, matched = btnUploadIDsFromText(string(responseBody)) + } + if !matched && groupID == "" && torrentID == "" { + failurePath, _ := commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "BTN", "upload-failure", responsePreview, ".html") + if failurePath != "" { + return api.UploadSummary{}, fmt.Errorf( + "%w failure=%s", + commonhttp.UploadHTTPErrorWithURL("BTN", resp.StatusCode, finalURL, responsePreview), + failurePath, + ) + } + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("BTN", resp.StatusCode, finalURL, responsePreview) + } + } + torrentURL := buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) + + if announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announceURL != "" && torrentID != "" && !torrentDownloaded { + if err := writeBTNTorrentArtifact(torrentPath, trackerTorrentPath, announceURL, torrentURL); err != nil { + return api.UploadSummary{}, err + } + torrentDownloaded = true + } + + if torrentID != "" && !torrentDownloaded { + if err := downloadTrackerTorrent(ctx, uploadCtx.client, uploadCtx.baseURL, torrentID, trackerTorrentPath); err != nil { + if req.Logger != nil { + req.Logger.Warnf("trackers: BTN torrent download fallback to API search: %s", commonhttp.RedactErrorDetail(err.Error())) + } + selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) + if err != nil { + return api.UploadSummary{}, err + } + torrentID = selectedID + if selectedGroupID != "" { + groupID = selectedGroupID + } + torrentURL = buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) + } + } else if torrentID == "" { + selectedID, selectedGroupID, err := resolveAndDownloadViaAPI(ctx, uploadCtx.apiURL, uploadCtx.apiToken, req, groupID, trackerTorrentPath) + if err != nil { + return api.UploadSummary{}, err + } + torrentID = selectedID + if selectedGroupID != "" { + groupID = selectedGroupID + } + torrentURL = buildBTNTorrentURL(uploadCtx.baseURL, groupID, torrentID) + } + + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "BTN", + TorrentID: torrentID, + TorrentURL: torrentURL, + DownloadURL: torrentURL, + TorrentPath: trackerTorrentPath, + }}, + }, nil +} + +// buildUploadDryRun returns a BTN preview entry with the exact payload that +// would be submitted locally. TV payloads that would serialize missing +// canonical season or episode metadata as zero are returned as blocked so the +// operator sees the remediation before upload. +func buildUploadDryRun(ctx context.Context, req trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + return buildUploadDryRunAt(ctx, req, btnDefaultBaseURL) +} + +func buildUploadDryRunAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (api.TrackerDryRunEntry, error) { + if err := validateBTNRequest(req); err != nil { + return api.TrackerDryRunEntry{}, err + } + + uploadCtx, err := newUploadContextAt(ctx, req, baseURL) + if err != nil { + return api.TrackerDryRunEntry{}, err + } + if err := validateBTNDryRunUploadAuth(ctx, req, uploadCtx); err != nil { + return api.TrackerDryRunEntry{}, err + } + + autofillPayload, uploadType := buildBTNAutofillPayload(req.Meta) + debugSections := make([]api.TrackerDryRunDebugSection, 0, 2) + if req.Intent == trackers.PreparationIntentDryRun { + debugSections = append(debugSections, api.TrackerDryRunDebugSection{ + Title: "BTN autofill request", + Endpoint: uploadCtx.uploadURL, + Payload: urlValuesToPayloadMap(autofillPayload), + }) + } + + payload := map[string]string{ + "submit": "true", + "type": uploadType, + "scenename": resolveUploadName(req.Meta), + "origin": resolveOrigin(req.Meta), + "release_desc": resolveBTNReleaseDesc(req.Meta), + "tvdb": "autofilled", + } + if resolveFastTorrent(req.TrackerConfig) { + payload["fasttorrent"] = "on" + } + + torrentPath, err := resolveTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return api.TrackerDryRunEntry{}, err + } + + message := "dry-run payload generated" + status := "ready" + if metadataMessage, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { + message += "; " + metadataMessage + status = "blocked" + } + if req.Intent == trackers.PreparationIntentDryRun && status == "ready" { + client, err := ensureBTNUploadSession(ctx, req.TrackerConfig, req.Runtime.DBPath, uploadCtx) + if err != nil { + return api.TrackerDryRunEntry{}, err + } + uploadCtx.client = client + fields, err := requestBTNAutofillFields(ctx, uploadCtx, autofillPayload, uploadType) + if err != nil { + return api.TrackerDryRunEntry{}, err + } + payload, err = buildBTNUploadPayload(req, fields) + if err != nil { + return api.TrackerDryRunEntry{}, err + } + message += "; BTN autofill debug completed" + debugSections = append(debugSections, api.TrackerDryRunDebugSection{ + Title: "BTN final upload payload after autofill", + Endpoint: uploadCtx.uploadURL, + Payload: payload, + Files: resolveBTNDryRunFiles(req.Meta, torrentPath), + }) + } + + return api.TrackerDryRunEntry{ + Tracker: "BTN", + Status: status, + Message: message, + ReleaseName: resolveUploadName(req.Meta), + DescriptionGroup: "btn", + Description: payload["release_desc"], + Endpoint: uploadCtx.uploadURL, + Payload: payload, + Files: resolveBTNDryRunFiles(req.Meta, torrentPath), + DebugSections: debugSections, + }, nil +} + +// validateBTNDryRunUploadAuth checks auth prerequisites needed before an upload +// can authenticate. Stored cookies must pass the same upload-page session gate +// as live upload; credential fallback remains local-only and does not log in or +// persist cookies during dry-run. +func validateBTNDryRunUploadAuth(ctx context.Context, req trackers.PreparationInput, uploadCtx uploadContext) error { + values, cookieErr := loadBTNCookies(ctx, req.Runtime.DBPath) + if cookieErr == nil && len(values) > 0 { + client, err := newBTNClientWithCookies(uploadCtx.baseURL, values) + if err != nil { + return err + } + if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err == nil { + return nil + } else if !errors.Is(err, errBTNSessionConfirmedInvalid) { + return err + } else if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { + return err + } + } + if cookieErr != nil && !errors.Is(cookieErr, errBTNCookiesMissing) { + return cookieErr + } + if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { + return errors.New("trackers: BTN cookie invalid/missing and username/password not configured") + } + return nil +} + +func newUploadContextAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (uploadContext, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return uploadContext{}, fmt.Errorf("trackers: BTN create cookie jar: %w", err) + } + client := &http.Client{Timeout: 45 * time.Second, Jar: jar} + uploadCtx := uploadContext{ + baseURL: baseURL, + uploadURL: baseURL + btnUploadPath, + apiToken: req.Runtime.BTNAPIToken, + apiURL: resolveBTNAPIURL(req.TrackerConfig), + client: client, + } + loadBTNCookiesIntoJar(ctx, client, req.Runtime.DBPath, baseURL) + return uploadCtx, nil +} + +// ensureBTNUploadSession validates imported BTN cookies before credential login. +// Credential login cookies are persisted only after the refreshed client reaches +// the upload page, so failed or incomplete logins do not replace stored auth. +func ensureBTNUploadSession(ctx context.Context, cfg config.TrackerConfig, dbPath string, uploadCtx uploadContext) (*http.Client, error) { + values, cookieErr := loadBTNCookies(ctx, dbPath) + if cookieErr == nil && len(values) > 0 { + if client, err := newBTNClientWithCookies(uploadCtx.baseURL, values); err == nil { + if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err == nil { + return client, nil + } else if !errors.Is(err, errBTNSessionConfirmedInvalid) { + return nil, err + } + } + } + if cookieErr != nil && !errors.Is(cookieErr, errBTNCookiesMissing) { + return nil, cookieErr + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: BTN cookie invalid/missing and username/password not configured") + } + client, err := loginBTNSession(ctx, cfg, uploadCtx.baseURL, api.TrackerAuthLoginRequest{}) + if err != nil { + return nil, err + } + if err := validateBTNClientSession(ctx, client, uploadCtx.baseURL); err != nil { + return nil, err + } + if err := persistBTNCookies(ctx, dbPath, uploadCtx.baseURL, client.Jar); err != nil { + return nil, fmt.Errorf("trackers: BTN persist cookies after successful login: %w", err) + } + return client, nil +} + +// ResolveSessionForTrackerAuth validates BTN stored cookies or logs in with +// configured credentials. Credential login must produce reusable cookies before +// refreshed cookies are persisted. +func ResolveSessionForTrackerAuth(ctx context.Context, cfg config.TrackerConfig, dbPath string) error { + return resolveSessionForTrackerAuthAt(ctx, cfg, dbPath, btnDefaultBaseURL) +} + +func resolveSessionForTrackerAuthAt(ctx context.Context, cfg config.TrackerConfig, dbPath string, baseURL string) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, api.TrackerAuthLoginRequest{}, baseURL) +} + +// ResolveSessionForTrackerAuthLogin validates BTN stored cookies or logs in +// with configured credentials. Refreshed cookies are persisted only after the +// upload page confirms the session. Manual login.Code is preferred over +// configured TOTP. Missing 2FA input preserves existing cookies; a rejected +// submitted code returns [ErrSubmitted2FARejected] before persistence. +func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, login, btnDefaultBaseURL) +} + +func resolveSessionForTrackerAuthLoginAt( + ctx context.Context, + cfg config.TrackerConfig, + dbPath string, + login api.TrackerAuthLoginRequest, + baseURL string, +) error { + err := validateBTNStoredCookies(ctx, baseURL, dbPath) + if err == nil { + return nil + } + if !errors.Is(err, errBTNCookiesMissing) && !errors.Is(err, errBTNSessionConfirmedInvalid) { + return err + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + if errors.Is(err, errBTNSessionConfirmedInvalid) { + return err + } + return errors.New("trackers: BTN cookie invalid/missing and username/password not configured") + } + client, err := loginBTNSession(ctx, cfg, baseURL, login) + if err != nil { + return err + } + if err := validateBTNClientSession(ctx, client, baseURL); err != nil { + if strings.TrimSpace(login.Code) != "" && errors.Is(err, errBTNSessionConfirmedInvalid) { + return fmt.Errorf("trackers: BTN submitted 2FA validation failed: %w", ErrSubmitted2FARejected) + } + return err + } + if err := persistBTNCookies(ctx, dbPath, baseURL, client.Jar); err != nil { + return fmt.Errorf("trackers: BTN persist cookies after successful login: %w", err) + } + return nil +} + +// validateBTNStoredCookies checks persisted BTN cookies against the upload page. +// Confirmed logged-out evidence is returned distinctly so tracker auth can delete +// stale cookies; storage/decrypt failures and ambiguous remote/parser failures +// preserve stored cookies and block credential login. +func validateBTNStoredCookies(ctx context.Context, baseURL string, dbPath string) error { + values, err := loadBTNCookies(ctx, dbPath) + if err != nil { + return err + } + if len(values) == 0 { + return errBTNCookiesMissing + } + client, err := newBTNClientWithCookies(baseURL, values) + if err != nil { + return err + } + return validateBTNClientSession(ctx, client, baseURL) +} + +// loginBTNSession performs the credential login step and leaves cookie +// persistence to callers after they validate the resulting upload session. +func loginBTNSession(ctx context.Context, cfg config.TrackerConfig, baseURL string, login api.TrackerAuthLoginRequest) (*http.Client, error) { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: BTN requires username/password") + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("trackers: BTN create login cookie jar: %w", err) + } + client := &http.Client{Timeout: 45 * time.Second, Jar: jar} + values := url.Values{} + values.Set("username", strings.TrimSpace(cfg.Username)) + values.Set("password", strings.TrimSpace(cfg.Password)) + values.Set("keeplogged", "1") + if code, err := resolveBTN2FACode(cfg, login); err == nil && code != "" { + values.Set("codenumber", code) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+btnLoginPath, strings.NewReader(values.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: BTN login request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "upbrr") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("trackers: BTN login request: %w", err) + } + defer resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if readErr != nil { + return nil, fmt.Errorf("trackers: BTN read login response: %w", readErr) + } + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + if strings.TrimSpace(login.Code) != "" && resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("trackers: BTN login failed status=%d: %w", resp.StatusCode, ErrSubmitted2FARejected) + } + return nil, fmt.Errorf("trackers: BTN login failed status=%d", resp.StatusCode) + } + bodyText := string(body) + if btnLoginNeeds2FA(bodyText) { + if strings.TrimSpace(login.Code) != "" { + return nil, fmt.Errorf("trackers: BTN login failed: %w", ErrSubmitted2FARejected) + } + if _, err := resolveBTN2FACode(config.TrackerConfig{OTPURI: cfg.OTPURI}, api.TrackerAuthLoginRequest{}); err != nil { + return nil, fmt.Errorf("trackers: BTN 2FA required: %w", err) + } + } + if btnLoginFailed(bodyText) { + if strings.TrimSpace(login.Code) != "" { + return nil, fmt.Errorf("trackers: BTN login failed: %w", ErrSubmitted2FARejected) + } + return nil, errors.New("trackers: BTN login failed") + } + return client, nil +} + +// validateBTNClientSession confirms the client can reach BTN's upload page. +// It treats explicit login redirects and logged-out markers as invalid session +// evidence while keeping layout misses and upstream failures transient. +func validateBTNClientSession(ctx context.Context, client *http.Client, baseURL string) error { + if client == nil { + return errors.New("trackers: BTN session client missing") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+btnUploadPath, nil) + if err != nil { + return fmt.Errorf("trackers: BTN upload auth request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("trackers: BTN upload auth request: %w", err) + } + defer resp.Body.Close() + finalPath := "" + if resp.Request != nil && resp.Request.URL != nil { + finalPath = strings.ToLower(resp.Request.URL.EscapedPath()) + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if readErr != nil { + return fmt.Errorf("trackers: BTN read upload auth response: %w", readErr) + } + bodyText := string(body) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || strings.Contains(finalPath, "login") || + btnLoggedOutPage(bodyText) { + return fmt.Errorf("%w: login required", errBTNSessionConfirmedInvalid) + } + if resp.StatusCode >= 500 { + return fmt.Errorf("trackers: BTN upload auth unavailable status=%d", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("trackers: BTN upload auth failed status=%d", resp.StatusCode) + } + if !btnLooksLikeUploadPage(bodyText) { + return errors.New("trackers: BTN upload auth page validation failed") + } + return nil +} + +func prepareUploadData(ctx context.Context, req trackers.PreparationInput, uploadCtx uploadContext) (map[string]string, error) { + if _, err := validateBTNTVPayloadMetadata(req.Meta); err != nil { + return nil, err + } + + autofillPayload, uploadType := buildBTNAutofillPayload(req.Meta) + fields, err := requestBTNAutofillFields(ctx, uploadCtx, autofillPayload, uploadType) + if err != nil { + return nil, err + } + return buildBTNUploadPayload(req, fields) +} + +// buildBTNAutofillPayload returns the form fields for BTN's first upload.php +// POST. TVDB-backed uploads submit the series id plus season or episode token; +// scene-name autofill is used only when no TVDB series id is available. +func buildBTNAutofillPayload(meta api.UploadSubject) (url.Values, string) { + autofillPayload := url.Values{} + uploadType := resolveUploadType(meta) + season, episode := resolveBTNTVSeasonEpisode(meta) + autofillPayload.Set("type", uploadType) + autofillPayload.Set("tvdb", "Get Info") + + if meta.Identity.TVDBID > 0 { + autofillPayload.Set("scene_yesno", "No") + autofillPayload.Set("auto_series", strconv.Itoa(meta.Identity.TVDBID)) + + if uploadType == "Episode" { + autofillPayload.Set("auto_title", fmt.Sprintf("S%02dE%02d", season, episode)) + } else { + autofillPayload.Set("auto_season", fmt.Sprintf("S%02d", season)) + } + } else { + autofillPayload.Set("scene_yesno", "Yes") + autofillPayload.Set("autofill", resolveUploadName(meta)) + } + + return autofillPayload, uploadType +} + +// requestBTNAutofillFields performs BTN's autofill POST and extracts the form +// values returned for the final upload payload. A validation failure means BTN +// did not return enough series/title data for the requested upload type. +func requestBTNAutofillFields(ctx context.Context, uploadCtx uploadContext, autofillPayload url.Values, uploadType string) (map[string]string, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadCtx.uploadURL, strings.NewReader(autofillPayload.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: BTN autofill request build: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := uploadCtx.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("trackers: BTN autofill request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("trackers: BTN autofill failed status=%d", resp.StatusCode) + } + htmlPayload, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if err != nil { + return nil, fmt.Errorf("trackers: BTN read autofill response: %w", err) + } + fields := extractAutofillFields(string(htmlPayload)) + if !validateAutofill(fields, uploadType) { + return nil, errors.New("trackers: BTN autofill validation failed") + } + return fields, nil +} + +// buildBTNUploadPayload merges BTN autofill fields with local metadata for the +// final upload form. BTN text fields such as album_desc are retained, release_desc +// is populated only from MediaInfo text, and local MediaInfo-derived dropdown +// mappings win when both sources provide a BTN-supported value. +func buildBTNUploadPayload(req trackers.PreparationInput, fields map[string]string) (map[string]string, error) { + format := mapContainer(req.Meta, fields) + bitrate := mapCodec(req.Meta, fields) + media := mapSource(req.Meta, fields) + if format == "" || bitrate == "" || media == "" { + return nil, fmt.Errorf("trackers: BTN dropdown mapping failed format=%q bitrate=%q media=%q", format, bitrate, media) + } + + title := metautil.FirstNonEmptyTrimmed(fields["title"]) + if resolveUploadType(req.Meta) == "Season" && title != "" { + isNumeric := true + for _, r := range title { + if r < '0' || r > '9' { + isNumeric = false + break + } + } + if isNumeric { + title = "Season " + title + } + } + + resolution := mapResolution(req.Meta, fields) + logBTNAutofillMismatch(req.Logger, "format", format, fields["format"]) + logBTNAutofillMismatch(req.Logger, "bitrate", bitrate, fields["bitrate"]) + logBTNAutofillMismatch(req.Logger, "media", media, fields["media"]) + logBTNAutofillMismatch(req.Logger, "resolution", resolution, fields["resolution"]) + payload := map[string]string{ + "submit": "true", + "type": resolveUploadType(req.Meta), + "scenename": applyBTNNameMapping(resolveUploadName(req.Meta), bitrate, media), + "seriesid": metautil.FirstNonEmptyTrimmed(fields["seriesid"]), + "artist": metautil.FirstNonEmptyTrimmed(fields["artist"]), + "title": title, + "actors": metautil.FirstNonEmptyTrimmed(fields["actors"]), + "origin": resolveOrigin(req.Meta), + "year": metautil.FirstNonEmptyTrimmed(fields["year"]), + "tags": resolveBTNTags(req.Meta, fields), + "image": resolveBTNImage(req.Meta, fields), + "album_desc": buildAlbumDesc(req.Meta, fields), + "format": format, + "bitrate": bitrate, + "media": media, + "resolution": resolution, + "release_desc": resolveBTNReleaseDesc(req.Meta), + "tvdb": "autofilled", + } + if resolveFastTorrent(req.TrackerConfig) { + payload["fasttorrent"] = "on" + } + if language := resolveBTNOriginalLanguage(req.Meta); language != "" && !isBTNEnglishLanguage(language) { + payload["foreign"] = "on" + if countryID := resolveCountryID(req.Meta); countryID != "" { + payload["country"] = countryID + } + } + clean := make(map[string]string, len(payload)) + for key, value := range payload { + if key != "release_desc" && strings.TrimSpace(value) == "" { + continue + } + clean[key] = value + } + return clean, nil +} + +// resolveBTNReleaseDesc returns the MediaInfo text BTN expects in release_desc. +// Description overrides are intentionally ignored for this field. +func resolveBTNReleaseDesc(meta api.UploadSubject) string { + return strings.TrimSpace(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath)) +} + +// urlValuesToPayloadMap flattens form values for debug display; BTN autofill +// fields are single-valued, so later values are intentionally ignored. +func urlValuesToPayloadMap(values url.Values) map[string]string { + out := make(map[string]string, len(values)) + for key, value := range values { + if len(value) == 0 { + continue + } + out[key] = value[0] + } + return out +} + +// logBTNAutofillMismatch records when BTN autofill selected a different +// dropdown value than local metadata. The upload still uses metadata because +// autofill runs before MediaInfo or final upload fields are submitted. +func logBTNAutofillMismatch(logger api.Logger, field string, metadataValue string, autofillValue string) { + if logger == nil { + return + } + field = strings.TrimSpace(field) + metadataValue = strings.TrimSpace(metadataValue) + autofillValue = strings.TrimSpace(autofillValue) + if field == "" || metadataValue == "" || autofillValue == "" || metadataValue == autofillValue { + return + } + logger.Infof("trackers: BTN autofill %s mismatch metadata_%s=%q autofill_%s=%q decision=metadata", field, field, metadataValue, field, autofillValue) +} + +// resolveBTNTags keeps BTN autofill genres when present and otherwise maps +// TVDB then IMDb genres to BTN-supported tag labels. +func resolveBTNTags(meta api.UploadSubject, fields map[string]string) string { + if tags := strings.TrimSpace(fields["tags"]); tags != "" { + return tags + } + if meta.ProviderMetadata.TVDB != nil { + if tags := mapBTNGenres(meta.ProviderMetadata.TVDB.Genres); tags != "" { + return tags + } + } + if meta.ProviderMetadata.IMDB != nil { + return mapBTNGenres(meta.ProviderMetadata.IMDB.Genres) + } + return "" +} + +// resolveBTNImage keeps BTN autofill poster data when present. Empty autofill +// falls back through TVDB, IMDb, TVmaze, then TMDB poster metadata. +func resolveBTNImage(meta api.UploadSubject, fields map[string]string) string { + if image := strings.TrimSpace(fields["image"]); image != "" { + return image + } + if meta.ProviderMetadata.TVDB != nil { + if poster := strings.TrimSpace(meta.ProviderMetadata.TVDB.Poster); poster != "" { + return poster + } + } + if meta.ProviderMetadata.IMDB != nil { + if poster := strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover); poster != "" { + return poster + } + } + if meta.ProviderMetadata.TVmaze != nil { + if poster := strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster); poster != "" { + return poster + } + if poster := strings.TrimSpace(meta.ProviderMetadata.TVmaze.PosterMedium); poster != "" { + return poster + } + } + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + return "" +} + +// mapBTNGenres converts provider genre text to comma-separated BTN genre tags. +// Unrecognized genres are omitted instead of being submitted as free-form tags. +func mapBTNGenres(genres string) string { + normalized := normalizeBTNGenreText(genres) + if normalized == "" { + return "" + } + type genreAlias struct { + label string + aliases []string + } + allowed := []genreAlias{ + {label: "Action", aliases: []string{"action"}}, + {label: "Adventure", aliases: []string{"adventure"}}, + {label: "Animation", aliases: []string{"animation"}}, + {label: "Anime", aliases: []string{"anime"}}, + {label: "Awards Show", aliases: []string{"awards show"}}, + {label: "Children", aliases: []string{"children", "kids"}}, + {label: "Comedy", aliases: []string{"comedy"}}, + {label: "Crime", aliases: []string{"crime"}}, + {label: "Documentary", aliases: []string{"documentary"}}, + {label: "Drama", aliases: []string{"drama"}}, + {label: "Family", aliases: []string{"family"}}, + {label: "Fantasy", aliases: []string{"fantasy"}}, + {label: "Food", aliases: []string{"food"}}, + {label: "Game Show", aliases: []string{"game show"}}, + {label: "History", aliases: []string{"history"}}, + {label: "Home and Garden", aliases: []string{"home and garden", "home garden"}}, + {label: "Horror", aliases: []string{"horror"}}, + {label: "Indie", aliases: []string{"indie"}}, + {label: "Martial Arts", aliases: []string{"martial arts"}}, + {label: "Mini-Series", aliases: []string{"mini series", "miniseries"}}, + {label: "Musical", aliases: []string{"musical", "music"}}, + {label: "Mystery", aliases: []string{"mystery"}}, + {label: "News", aliases: []string{"news"}}, + {label: "Podcast", aliases: []string{"podcast"}}, + {label: "Reality", aliases: []string{"reality"}}, + {label: "Romance", aliases: []string{"romance"}}, + {label: "Science Fiction", aliases: []string{"science fiction", "sci fi", "scifi"}}, + {label: "Soap", aliases: []string{"soap"}}, + {label: "Sport", aliases: []string{"sport", "sports"}}, + {label: "Suspense", aliases: []string{"suspense"}}, + {label: "Talk Show", aliases: []string{"talk show"}}, + {label: "Thriller", aliases: []string{"thriller"}}, + {label: "Travel", aliases: []string{"travel"}}, + {label: "War", aliases: []string{"war"}}, + {label: "Western", aliases: []string{"western"}}, + } + tags := make([]string, 0, len(allowed)) + for _, genre := range allowed { + for _, alias := range genre.aliases { + if normalizedBTNGenreContains(normalized, alias) { + tags = append(tags, genre.label) + break + } + } + } + return strings.Join(tags, ", ") +} + +// normalizeBTNGenreText lowercases and strips common separators so source +// genre text and local aliases can be compared consistently. +func normalizeBTNGenreText(value string) string { + replacer := strings.NewReplacer( + "&", " and ", + "/", " ", + ";", " ", + ":", " ", + ".", " ", + ",", " ", + "-", " ", + "_", " ", + "(", " ", + ")", " ", + ) + return strings.Join(strings.Fields(replacer.Replace(strings.ToLower(strings.TrimSpace(value)))), " ") +} + +// normalizedBTNGenreContains reports whether a normalized genre list contains +// an alias as a complete token sequence. +func normalizedBTNGenreContains(normalized string, alias string) bool { + alias = normalizeBTNGenreText(alias) + return strings.Contains(" "+normalized+" ", " "+alias+" ") +} + +// extractAutofillFields reads BTN's autofilled upload form into the field map +// consumed by final payload construction. Input values, selected dropdown +// values, and the album_desc textarea are normalized to lower-case field names. +func extractAutofillFields(htmlRaw string) map[string]string { + fields := map[string]string{} + for _, match := range btnInputPattern.FindAllStringSubmatch(htmlRaw, -1) { + if len(match) < 3 { + continue + } + fields[strings.ToLower(strings.TrimSpace(match[1]))] = html.UnescapeString(strings.TrimSpace(match[2])) + } + if match := btnTextAreaPattern.FindStringSubmatch(htmlRaw); len(match) > 1 { + fields["album_desc"] = html.UnescapeString(strings.TrimSpace(stripHTML(match[1]))) + } + for _, selectMatch := range btnSelectPattern.FindAllStringSubmatch(htmlRaw, -1) { + if len(selectMatch) < 3 { + continue + } + name := strings.ToLower(strings.TrimSpace(selectMatch[1])) + body := selectMatch[2] + if value := selectedBTNAutofillOptionValue(body); value != "" { + fields[name] = value + } + } + return fields +} + +// selectedBTNAutofillOptionValue returns the selected option value regardless +// of attribute order, falling back to the first option value when no option is +// marked selected. +func selectedBTNAutofillOptionValue(body string) string { + tokenizer := xhtml.NewTokenizer(strings.NewReader(body)) + firstValue := "" + for { + switch tokenizer.Next() { + case xhtml.ErrorToken: + return firstValue + case xhtml.StartTagToken, xhtml.SelfClosingTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "option") { + continue + } + value := "" + selected := false + for _, attr := range token.Attr { + switch { + case strings.EqualFold(attr.Key, "value"): + value = html.UnescapeString(strings.TrimSpace(attr.Val)) + case strings.EqualFold(attr.Key, "selected"): + selected = true + } + } + if value == "" { + continue + } + if firstValue == "" { + firstValue = value + } + if selected { + return value + } + case xhtml.TextToken, xhtml.EndTagToken, xhtml.CommentToken, xhtml.DoctypeToken: + continue + } + } +} + +// validateAutofill reports whether BTN returned the minimum autofill fields +// needed for the requested upload type. Episodes require a title; season packs +// only require a series artist. +func validateAutofill(fields map[string]string, uploadType string) bool { + artist := strings.TrimSpace(fields["artist"]) + title := strings.TrimSpace(fields["title"]) + if artist == "" { + return false + } + if uploadType == "Episode" && title == "" { + return false + } + if strings.EqualFold(artist, "autofill fail") || strings.EqualFold(title, "autofill fail") { + return false + } + return true +} + +// preferredBTNTVDBEpisodeTitle returns TVDB's English episode title when it is +// available, falling back to the original-language title. +func preferredBTNTVDBEpisodeTitle(tvdb *api.TVDBMetadata) string { + if tvdb == nil { + return "" + } + return metautil.FirstNonEmptyTrimmed(strings.TrimSpace(tvdb.EpisodeNameEnglish), strings.TrimSpace(tvdb.EpisodeName)) +} + +// preferredBTNIMDBEpisodeTitle returns the selected IMDb episode title when an +// episode-specific IMDb entry can be matched to the upload metadata. +func preferredBTNIMDBEpisodeTitle(meta api.UploadSubject) string { + if episode := preferredBTNIMDBEpisode(meta); episode != nil { + return strings.TrimSpace(episode.Title) + } + return "" +} + +// preferredBTNTVDBOverview returns TVDB episode overview text before series +// overview text, using English translations before original-language values. +func preferredBTNTVDBOverview(tvdb *api.TVDBMetadata) string { + if tvdb == nil { + return "" + } + return metautil.FirstNonEmptyTrimmed( + strings.TrimSpace(tvdb.EpisodeOverviewEnglish), + strings.TrimSpace(tvdb.EpisodeOverview), + strings.TrimSpace(tvdb.OverviewEnglish), + strings.TrimSpace(tvdb.Overview), + ) +} + +// preferredBTNIMDBOverview returns IMDb plot text when TVDB overview data is +// unavailable for the BTN description block. +func preferredBTNIMDBOverview(imdb *api.IMDBMetadata) string { + if imdb == nil { + return "" + } + return strings.TrimSpace(imdb.Plot) +} + +// buildAlbumDesc keeps BTN's autofilled album_desc when available. If BTN did +// not return one, TV uploads fall back to BBCode episode blocks from TVDB or +// provider/local episode metadata. +func buildAlbumDesc(meta api.UploadSubject, fields map[string]string) string { + if desc := metautil.FirstNonEmptyTrimmed(fields["album_desc"]); desc != "" { + return desc + } + if !strings.EqualFold(strings.TrimSpace(string(meta.Identity.Category)), "TV") { + return "" + } + if desc := buildTVDBAlbumDesc(meta); desc != "" { + return desc + } + tvdb := meta.ProviderMetadata.TVDB + overview := metautil.FirstNonEmptyTrimmed( + preferredBTNTVDBOverview(tvdb), + preferredBTNIMDBOverview(meta.ProviderMetadata.IMDB), + strings.TrimSpace(meta.EpisodeOverview), + ) + aired := metautil.FirstNonEmptyTrimmed( + btnTVDBEpisodeAired(tvdb), + btnIMDBEpisodeAired(meta), + strings.TrimSpace(meta.TVDBAiredDate), + strings.TrimSpace(meta.DailyEpisodeDate), + "TBA", + ) + season, episode := resolveBTNTVSeasonEpisode(meta) + episodeTitle := metautil.FirstNonEmptyTrimmed( + preferredBTNTVDBEpisodeTitle(tvdb), + preferredBTNIMDBEpisodeTitle(meta), + strings.TrimSpace(meta.EpisodeTitle), + "TBA", + ) + return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{{ + SeasonNumber: season, + EpisodeNumber: episode, + EpisodeName: episodeTitle, + EpisodeOverview: overview, + EpisodeAired: aired, + }}) +} + +// buildTVDBAlbumDesc builds BTN album_desc from TVDB data only. Episode uploads +// use the selected episode; season-pack uploads use every fetched episode in +// the selected season. +func buildTVDBAlbumDesc(meta api.UploadSubject) string { + tvdb := meta.ProviderMetadata.TVDB + if tvdb == nil { + return "" + } + season, episode := resolveBTNTVSeasonEpisode(meta) + if resolveUploadType(meta) == "Season" { + episodes := make([]api.TVDBEpisodeMetadata, 0, len(tvdb.Episodes)) + for _, candidate := range tvdb.Episodes { + if candidate.SeasonNumber == season { + episodes = append(episodes, candidate) + } + } + sort.SliceStable(episodes, func(i, j int) bool { + return episodes[i].EpisodeNumber < episodes[j].EpisodeNumber + }) + return formatBTNEpisodeAlbumDesc(episodes) + } + for _, candidate := range tvdb.Episodes { + if candidate.SeasonNumber == season && candidate.EpisodeNumber == episode { + return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{candidate}) + } + } + if tvdb.EpisodeSeason > 0 || tvdb.EpisodeNumber > 0 || strings.TrimSpace(tvdb.EpisodeName) != "" || strings.TrimSpace(tvdb.EpisodeOverview) != "" { + return formatBTNEpisodeAlbumDesc([]api.TVDBEpisodeMetadata{{ + SeasonNumber: tvdb.EpisodeSeason, + EpisodeNumber: tvdb.EpisodeNumber, + EpisodeName: tvdb.EpisodeName, + EpisodeNameEnglish: tvdb.EpisodeNameEnglish, + EpisodeOverview: tvdb.EpisodeOverview, + EpisodeOverviewEnglish: tvdb.EpisodeOverviewEnglish, + EpisodeAired: tvdb.EpisodeAired, + EpisodeImage: tvdb.EpisodeImage, + }}) + } + return "" +} + +// formatBTNEpisodeAlbumDesc renders each episode as the BBCode block expected +// by BTN's album_desc field. Empty input returns an empty string. +func formatBTNEpisodeAlbumDesc(episodes []api.TVDBEpisodeMetadata) string { + blocks := make([]string, 0, len(episodes)) + for _, episode := range episodes { + title := metautil.FirstNonEmptyTrimmed(episode.EpisodeNameEnglish, episode.EpisodeName, "TBA") + overview := metautil.FirstNonEmptyTrimmed(episode.EpisodeOverviewEnglish, episode.EpisodeOverview) + aired := metautil.FirstNonEmptyTrimmed(episode.EpisodeAired, "TBA") + var block strings.Builder + block.WriteString("[b]Episode Name:[/b] ") + block.WriteString(title) + block.WriteString("\n[b]Season:[/b] ") + block.WriteString(strconv.Itoa(episode.SeasonNumber)) + block.WriteString("\n[b]Episode:[/b] ") + block.WriteString(strconv.Itoa(episode.EpisodeNumber)) + block.WriteString("\n[b]Aired:[/b] ") + block.WriteString(aired) + block.WriteString("\n\n[b]Episode overview:[/b]\n") + block.WriteString(overview) + if image := strings.TrimSpace(episode.EpisodeImage); image != "" { + block.WriteString("\n\n[b]Episode image:[/b]\n[img=") + block.WriteString(image) + block.WriteString("]") + } + blocks = append(blocks, strings.TrimSpace(block.String())) + } + return strings.Join(blocks, "\n\n") +} + +// btnTVDBEpisodeAired returns the TVDB episode air date used in BTN-generated +// description text. An empty value leaves metadata date fallbacks in control. +func btnTVDBEpisodeAired(tvdb *api.TVDBMetadata) string { + if tvdb == nil { + return "" + } + return strings.TrimSpace(tvdb.EpisodeAired) +} + +// btnIMDBEpisodeAired returns the selected IMDb episode release date in the +// most specific YYYY[-MM[-DD]] form available. +func btnIMDBEpisodeAired(meta api.UploadSubject) string { + episode := preferredBTNIMDBEpisode(meta) + if episode == nil || episode.ReleaseDate.Year <= 0 { + return "" + } + if episode.ReleaseDate.Month <= 0 { + return strconv.Itoa(episode.ReleaseDate.Year) + } + if episode.ReleaseDate.Day <= 0 { + return fmt.Sprintf("%04d-%02d", episode.ReleaseDate.Year, episode.ReleaseDate.Month) + } + return fmt.Sprintf("%04d-%02d-%02d", episode.ReleaseDate.Year, episode.ReleaseDate.Month, episode.ReleaseDate.Day) +} + +// preferredBTNIMDBEpisode returns the IMDb episode entry for this upload when +// the canonical season and episode identify one, or the sole available IMDb +// episode when the metadata payload already represents a single episode. +func preferredBTNIMDBEpisode(meta api.UploadSubject) *api.IMDBEpisode { + if meta.ProviderMetadata.IMDB == nil || len(meta.ProviderMetadata.IMDB.Episodes) == 0 { + return nil + } + episodes := meta.ProviderMetadata.IMDB.Episodes + season, episode := meta.CanonicalSeasonEpisode() + if season > 0 && episode > 0 { + for i := range episodes { + if episodes[i].Season != season { + continue + } + if btnIMDBEpisodeNumber(episodes[i].EpisodeText) == episode { + return &episodes[i] + } + } + } + if len(episodes) == 1 { + return &episodes[0] + } + return nil +} + +// btnIMDBEpisodeNumber parses IMDb episode text such as "7", "E07", or +// "Episode 7" into the numeric episode value BTN expects. +func btnIMDBEpisodeNumber(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + if parsed, err := strconv.Atoi(value); err == nil { + return parsed + } + matches := btnIMDBEpisodePattern.FindStringSubmatch(value) + if len(matches) < 2 { + return 0 + } + parsed, err := strconv.Atoi(matches[1]) + if err != nil { + return 0 + } + return parsed +} + +// validateBTNTVPayloadMetadata returns the shared BTN TV metadata block reason +// used by live upload, autofill, and dry-run when canonical season or episode +// data is missing. +func validateBTNTVPayloadMetadata(meta api.UploadSubject) (string, error) { + message := btnTVPayloadMetadataMessage(meta) + if message == "" { + return "", nil + } + return message, errors.New("trackers: BTN " + message) +} + +func resolveUploadType(meta api.UploadSubject) string { + if meta.TVPack { + return "Season" + } + _, episode := resolveBTNTVSeasonEpisode(meta) + if episode > 0 { + return "Episode" + } + return "Season" +} + +// resolveBTNTVSeasonEpisode returns the season and episode numbers BTN should +// use for generated request and description fields. TVDB episode numbers win +// over IMDb episode data, then metadata ints; missing values fall back +// independently. +func resolveBTNTVSeasonEpisode(meta api.UploadSubject) (int, int) { + season, episode := meta.CanonicalSeasonEpisode() + tvdb := meta.ProviderMetadata.TVDB + imdbEpisode := preferredBTNIMDBEpisode(meta) + if tvdb != nil && tvdb.EpisodeSeason > 0 { + season = meta.ProviderMetadata.TVDB.EpisodeSeason + } else if imdbEpisode != nil && imdbEpisode.Season > 0 { + season = imdbEpisode.Season + } + if tvdb != nil && tvdb.EpisodeNumber > 0 { + episode = meta.ProviderMetadata.TVDB.EpisodeNumber + } else if imdbEpisode != nil { + if imdbEpisodeNumber := btnIMDBEpisodeNumber(imdbEpisode.EpisodeText); imdbEpisodeNumber > 0 { + episode = imdbEpisodeNumber + } + } + return season, episode +} + +// btnTVPayloadMetadataMessage explains when BTN cannot build TV season or +// episode fields because canonical metadata is absent. Parsed release values are +// reported only as ignored signals, and the message includes the operator action +// required by blocked dry-run entries. +func btnTVPayloadMetadataMessage(meta api.UploadSubject) string { + if !strings.EqualFold(strings.TrimSpace(string(meta.Identity.Category)), "TV") { + return "" + } + missing := make([]string, 0, 2) + ignored := make([]string, 0, 2) + season, episode := resolveBTNTVSeasonEpisode(meta) + if season <= 0 { + missing = append(missing, "season") + if meta.Release.Season > 0 { + ignored = append(ignored, "season") + } + } + if episode <= 0 && !meta.TVPack { + missing = append(missing, "episode") + if meta.Release.Episode > 0 { + ignored = append(ignored, "episode") + } + } + if len(missing) == 0 { + return "" + } + message := "canonical TV " + strings.Join(missing, "/") + " missing; BTN upload requires TVDB or metadata season/episode ints" + if len(ignored) > 0 { + message += " and ignores parsed " + strings.Join(ignored, "/") + " fallback" + } + message += "; refresh metadata or correct canonical season/episode before upload" + return message +} + +// resolveOrigin derives the origin from +// prepared scene and season-pack metadata, group tag and username +func resolveOrigin(meta api.UploadSubject) string { + group := strings.TrimSpace(meta.Release.Group) + if seasonPackHasMixedGroups(meta) { + return "Mixed" + } + if isBTNSceneRelease(meta) { + return "Scene" + } + if group != "" && isNoGroupTag(group) || isBTNInternalGroup(meta) { + return "None" + } + return "P2P" +} + +func seasonPackHasMixedGroups(meta api.UploadSubject) bool { + if !meta.TVPack || len(meta.FileList) < 2 { + return false + } + + groups := make(map[string]struct{}) + for _, file := range meta.FileList { + base := pathutil.Base(strings.TrimSpace(file)) + if base == "" || base == "." || base == "/" { + continue + } + parsed := rls.ParseString(base) + group := strings.TrimSpace(parsed.Group) + if group == "" { + site := strings.TrimSpace(parsed.Site) + if site != "" && strings.HasPrefix(strings.TrimSpace(base), "["+site+"]") { + group = site + } + } + group = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(group, "-"))) + switch group { + case "", "nogrp", "nogroup", "unknown", "unk": + continue + } + groups[group] = struct{}{} + if len(groups) > 1 { + return true + } + } + return false +} + +// stripEpisodeTitle removes generated episode-title text from BTN upload names +// unless the original filename already carried the same token sequence. +func stripEpisodeTitle(name string, episodeTitle string, filename string) string { + if episodeTitle == "" || name == "" { + return name + } + if metautil.ReleaseNameContainsEpisodeTitle(filename, episodeTitle) { + return name + } + return metautil.RemoveEpisodeTitleFromReleaseName(name, episodeTitle) +} + +func resolveUploadName(meta api.UploadSubject) string { + var name string + if n := strings.TrimSpace(meta.ReleaseName); n != "" { + name = n + } else if n := strings.TrimSpace(meta.ReleaseNameNoTag); n != "" { + name = n + } else if n := strings.TrimSpace(meta.Filename); n != "" { + name = n + } else { + name = pathutil.Base(meta.SourcePath) + } + name = stripEpisodeTitle(name, meta.EpisodeTitle, btnEpisodeTitleFilename(meta)) + name = cleanAndNormalizeBTNName(name) + return applyBTNNoGroupSuffix(name, meta) +} + +// btnEpisodeTitleFilename returns the original filename text used to decide +// whether BTN should preserve an episode title in the upload name. +func btnEpisodeTitleFilename(meta api.UploadSubject) string { + return metautil.FirstNonEmptyTrimmed(meta.Filename, pathutil.Base(meta.SourcePath)) +} + +// applyBTNNoGroupSuffix preserves a valid prepared group tag for no-tag names +// and falls back to BTN's NOGRP marker when no usable tag is available. +func applyBTNNoGroupSuffix(name string, meta api.UploadSubject) string { + tag := strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")) + + if tag != "" && !isNoGroupTag(tag) { + if selectedBTNReleaseNameNoTag(name, meta) || !hasBTNGroupSuffix(name) { + return strings.TrimRight(name, ".-") + "-" + tag + } + return name + } + + // Preserve an existing release-name suffix when parsing did not provide a + // group tag; only known placeholder suffixes should be normalized below. + if tag == "" && hasBTNGroupSuffix(name) && !hasBTNNoGroupSuffix(name) { + return name + } + + noGroupPattern := regexp.MustCompile(`(?i)-(nogrp|nogroup|unknown|unk)$`) + normalizedName := noGroupPattern.ReplaceAllString(name, "") + normalizedName = strings.TrimRight(normalizedName, ".-") + + return normalizedName + "-NOGRP" +} + +// selectedBTNReleaseNameNoTag reports whether name is the normalized +// ReleaseNameNoTag value chosen by resolveUploadName. +func selectedBTNReleaseNameNoTag(name string, meta api.UploadSubject) bool { + if strings.TrimSpace(meta.ReleaseName) != "" || strings.TrimSpace(meta.ReleaseNameNoTag) == "" { + return false + } + candidate := stripEpisodeTitle(strings.TrimSpace(meta.ReleaseNameNoTag), meta.EpisodeTitle, btnEpisodeTitleFilename(meta)) + candidate = cleanAndNormalizeBTNName(candidate) + return strings.TrimSpace(name) == candidate +} + +// hasBTNGroupSuffix reports whether name already ends with a hyphenated BTN +// group suffix. +func hasBTNGroupSuffix(name string) bool { + return regexp.MustCompile(`-[^-.\s]+$`).MatchString(strings.TrimSpace(name)) +} + +// hasBTNNoGroupSuffix reports whether name ends with a placeholder group suffix +// that BTN should normalize to NOGRP. +func hasBTNNoGroupSuffix(name string) bool { + return regexp.MustCompile(`(?i)-(nogrp|nogroup|unknown|unk)$`).MatchString(strings.TrimSpace(name)) +} + +func isNoGroupTag(tag string) bool { + value := strings.ToLower(strings.TrimSpace(tag)) + switch value { + case "nogrp", "nogroup", "unknown", "unk": + return true + default: + return false + } +} + +func removeDiacritics(s string) string { + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + result, _, _ := transform.String(t, s) + return result +} + +// cleanAndNormalizeBTNName converts prepared release names into BTN scene-name +// syntax. It removes diacritics, uses dots as token separators, compacts known +// audio channel tokens, and keeps hyphens for group tags. +func cleanAndNormalizeBTNName(value string) string { + // 0. Remove diacritics + value = removeDiacritics(value) + + // 1. Dot normalization (spaces to dots, collapse dots) + value = strings.Join(strings.Fields(value), " ") + value = strings.ReplaceAll(value, " ", ".") + + // 2. Replace plus in DD+ + value = strings.ReplaceAll(value, "DD+", "DDP") + + for _, rule := range btnNameNormalizationRules { + value = rule.pattern.ReplaceAllString(value, rule.replacement) + } + + return strings.TrimSpace(value) +} + +func resolveTorrentPath(meta api.UploadSubject, dbPath string) (string, error) { + candidates := []string{strings.TrimSpace(meta.TorrentPath), strings.TrimSpace(meta.ClientTorrentPath), strings.TrimSpace(meta.SourcePath)} + for _, candidate := range candidates { + if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err == nil { + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err == nil { + guessed := filepath.Join(tmpDir, base+".torrent") + if info, err := os.Stat(guessed); err == nil && !info.IsDir() { + return guessed, nil + } + } + } + } + return "", errors.New("trackers: BTN torrent file not found") +} + +// resolveBTNUploadFiles returns the multipart file parts BTN accepts for an +// upload. Scene NFOs are attached only when scene metadata confirms the release +// and the prepared NFO file still exists. +func resolveBTNUploadFiles(meta api.UploadSubject, torrentPath string) []commonhttp.FileField { + files := []commonhttp.FileField{{ + FieldName: "file_input", + Path: torrentPath, + FileName: "torrent.torrent", + }} + if nfoPath := resolveBTNSceneNFOPath(meta); nfoPath != "" { + files = append(files, commonhttp.FileField{ + FieldName: "nfo", + Path: nfoPath, + FileName: filepath.Base(nfoPath), + }) + } + return files +} + +// resolveBTNDryRunFiles mirrors the upload file parts without reading file +// content so previews show whether BTN will receive an NFO. +func resolveBTNDryRunFiles(meta api.UploadSubject, torrentPath string) []api.TrackerDryRunFile { + files := []api.TrackerDryRunFile{{ + Field: "file_input", + Path: torrentPath, + Present: strings.TrimSpace(torrentPath) != "", + }} + if nfoPath := resolveBTNSceneNFOPath(meta); nfoPath != "" { + files = append(files, api.TrackerDryRunFile{ + Field: "nfo", + Path: nfoPath, + Present: true, + }) + } + return files +} + +func resolveBTNSceneNFOPath(meta api.UploadSubject) string { + if !isBTNSceneRelease(meta) { + return "" + } + nfoPath := strings.TrimSpace(meta.SceneNFOPath) + if nfoPath == "" { + return "" + } + if info, err := os.Stat(nfoPath); err == nil && !info.IsDir() { + return nfoPath + } + return "" +} + +func isBTNSceneRelease(meta api.UploadSubject) bool { + return meta.Scene || strings.TrimSpace(meta.SceneName) != "" +} + +func resolveTrackerTorrentPath(meta api.UploadSubject, dbPath string, tracker string) (string, error) { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "", errors.New("trackers: BTN tracker torrent path requires db path and source path") + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: BTN tmp root: %w", err) + } + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: BTN tmp release dir: %w", err) + } + name := strings.ToLower(strings.TrimSpace(tracker)) + if name == "" { + name = "tracker" + } + return filepath.Join(tmpDir, base+"."+name+".torrent"), nil +} + +// writeBTNTorrentArtifact rewrites the uploaded torrent with the configured BTN +// announce URL once the canonical torrent URL is known. A successful write lets +// upload skip downloading BTN's generated torrent file. +func writeBTNTorrentArtifact(sourcePath string, outputPath string, announceURL string, torrentURL string) error { + if err := trackers.WritePersonalizedTorrent(sourcePath, outputPath, announceURL, torrentURL, "BTN"); err != nil { + return fmt.Errorf("trackers: BTN write torrent artifact: %w", err) + } + return nil +} + +// downloadTrackerTorrent fetches BTN's generated torrent file for uploads that +// do not have a configured announce URL. Non-torrent responses are rejected so +// callers can fall back to API resolution. +func downloadTrackerTorrent(ctx context.Context, client *http.Client, baseURL string, torrentID string, outputPath string) error { + if strings.TrimSpace(torrentID) == "" { + return errors.New("trackers: BTN torrent_id missing") + } + downloadURL := strings.TrimRight(baseURL, "/") + "/torrents.php?action=download&id=" + url.QueryEscape(strings.TrimSpace(torrentID)) + return downloadBTNTorrentURL(ctx, client, downloadURL, outputPath) +} + +// downloadBTNTorrentURL fetches a BTN torrent download URL and writes only a +// bencoded torrent payload to outputPath. +func downloadBTNTorrentURL(ctx context.Context, client *http.Client, downloadURL string, outputPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return fmt.Errorf("trackers: BTN torrent download request build: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("trackers: BTN torrent download request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024)) + if err != nil { + return fmt.Errorf("trackers: BTN read torrent response: %w", err) + } + if len(body) == 0 || body[0] != 'd' { + return errors.New("not a torrent payload") + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return fmt.Errorf("trackers: BTN create torrent output dir: %w", err) + } + if err := os.WriteFile(outputPath, body, 0o600); err != nil { + return fmt.Errorf("trackers: BTN write torrent output: %w", err) + } + return nil +} + +type btnUploadIntermediateResult struct { + groupID string + torrentID string +} + +// resolveBTNUploadIntermediatePage handles BTN's post-upload warning page that +// requires continuing to the canonical torrent page before the final torrent id +// is available. It returns handled=false for ordinary upload responses. +func resolveBTNUploadIntermediatePage( + ctx context.Context, + client *http.Client, + baseURL string, + currentURL string, + body []byte, +) (btnUploadIntermediateResult, bool, error) { + if !isBTNUploadIntermediatePage(body) { + return btnUploadIntermediateResult{}, false, nil + } + + result := btnUploadIntermediateResult{} + detailURL, detailGroupID, detailTorrentID, ok := findBTNUploadDetailURL(baseURL, currentURL, body) + canonicalTorrentID := false + if ok { + result.groupID = detailGroupID + if detailTorrentID != "" { + result.torrentID = detailTorrentID + canonicalTorrentID = true + } + } + + if !ok { + return result, true, errors.New("trackers: BTN intermediate page missing torrent detail link") + } + detailBody, detailFinalURL, err := fetchBTNTorrentDetailPage(ctx, client, detailURL) + if err != nil { + return result, true, err + } + if groupID, torrentID, matched := btnUploadIDsFromText(detailFinalURL); matched { + result.groupID = groupID + if torrentID != "" { + result.torrentID = torrentID + return result, true, nil + } + } + if groupID, torrentID, matched := btnUploadIDsFromText(string(detailBody)); matched { + result.groupID = groupID + if torrentID != "" { + result.torrentID = torrentID + return result, true, nil + } + } + if result.groupID == "" || !canonicalTorrentID { + return result, true, errors.New("trackers: BTN intermediate detail page missing canonical torrent id") + } + return result, true, nil +} + +// isBTNUploadIntermediatePage detects BTN's warning page that appears after a +// successful upload before the user has downloaded the generated torrent file. +func isBTNUploadIntermediatePage(body []byte) bool { + normalized := strings.ToLower(html.UnescapeString(string(body))) + return strings.Contains(normalized, "download the torrent file") || + strings.Contains(normalized, "need to download the torrent") +} + +// findBTNUploadDetailURL extracts the same-origin canonical torrent page URL +// from an intermediate BTN upload page. +func findBTNUploadDetailURL(baseURL string, currentURL string, body []byte) (string, string, string, bool) { + for _, raw := range btnHTMLURLAttrPattern.FindAllStringSubmatch(string(body), -1) { + if len(raw) < 2 { + continue + } + candidate, ok := resolveBTNSameOriginURL(baseURL, currentURL, raw[1]) + if !ok || !strings.EqualFold(candidate.Path, "/torrents.php") { + continue + } + query := candidate.Query() + if strings.EqualFold(query.Get("action"), "download") { + continue + } + groupID := strings.TrimSpace(query.Get("id")) + if groupID == "" { + continue + } + torrentID := strings.TrimSpace(query.Get("torrentid")) + return candidate.String(), groupID, torrentID, true + } + return "", "", "", false +} + +// resolveBTNSameOriginURL resolves an HTML URL attribute against the current +// BTN page and accepts only URLs on the configured BTN origin. +func resolveBTNSameOriginURL(baseURL string, currentURL string, rawURL string) (*url.URL, bool) { + base, err := url.Parse(baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, false + } + referenceBase := base + if parsedCurrent, err := url.Parse(currentURL); err == nil && parsedCurrent.Scheme != "" && parsedCurrent.Host != "" { + referenceBase = parsedCurrent + } + rawURL = strings.TrimSpace(html.UnescapeString(rawURL)) + if rawURL == "" { + return nil, false + } + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, false + } + resolved := referenceBase.ResolveReference(parsed) + if !strings.EqualFold(resolved.Scheme, base.Scheme) || !strings.EqualFold(resolved.Host, base.Host) { + return nil, false + } + return resolved, true +} + +// fetchBTNTorrentDetailPage follows the intermediate-page continue target and +// returns the bounded HTML body with the final URL after redirects. +func fetchBTNTorrentDetailPage(ctx context.Context, client *http.Client, detailURL string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil) + if err != nil { + return nil, "", fmt.Errorf("trackers: BTN intermediate detail request build: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("trackers: BTN intermediate detail request: %w", err) + } + defer resp.Body.Close() + finalURL := detailURL + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, finalURL, fmt.Errorf("trackers: BTN intermediate detail failed status=%d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if err != nil { + return nil, finalURL, fmt.Errorf("trackers: BTN read intermediate detail response: %w", err) + } + return body, finalURL, nil +} + +// btnUploadIDsFromText extracts BTN group and torrent ids from a URL or HTML +// fragment, including HTML-escaped query separators. +func btnUploadIDsFromText(value string) (string, string, bool) { + matches := btnSuccessURLPattern.FindStringSubmatch(html.UnescapeString(value)) + if len(matches) < 2 { + return "", "", false + } + groupID := strings.TrimSpace(matches[1]) + torrentID := "" + if len(matches) > 2 { + torrentID = strings.TrimSpace(matches[2]) + } + return groupID, torrentID, groupID != "" +} + +// buildBTNTorrentURL returns BTN's canonical torrent detail URL for a group and +// optional torrent id. +func buildBTNTorrentURL(baseURL string, groupID string, torrentID string) string { + torrentURL := strings.TrimRight(baseURL, "/") + "/torrents.php?id=" + url.QueryEscape(strings.TrimSpace(groupID)) + if strings.TrimSpace(torrentID) != "" { + torrentURL += "&torrentid=" + url.QueryEscape(strings.TrimSpace(torrentID)) + } + return torrentURL +} + +// decodeBTNAPIJSON reads a bounded BTN JSON-RPC response, rejects duplicate +// object keys, and unmarshals the single JSON value into dest. +func decodeBTNAPIJSON(r io.Reader, dest any) error { + payload, err := io.ReadAll(io.LimitReader(r, btnAPIJSONMaxBytes+1)) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + if len(payload) > btnAPIJSONMaxBytes { + return fmt.Errorf("response body exceeds %d bytes", btnAPIJSONMaxBytes) + } + if err := validateBTNJSONNoDuplicateObjectNames(payload); err != nil { + return err + } + if err := json.Unmarshal(payload, dest); err != nil { + return fmt.Errorf("unmarshal response body: %w", err) + } + return nil +} + +func callBTNAPI(ctx context.Context, apiURL, id, method string, params []any, dest any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + encoded, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("trackers: BTN API %s encode: %w", method, err) + } + apiReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(encoded)) + if err != nil { + return fmt.Errorf("trackers: BTN API %s request build: %w", method, err) + } + apiReq.Header.Set("Content-Type", "application/json") + apiResp, err := (&http.Client{Timeout: 30 * time.Second}).Do(apiReq) + if err != nil { + return fmt.Errorf("trackers: BTN API %s request: %w", method, err) + } + defer apiResp.Body.Close() + if apiResp.StatusCode < 200 || apiResp.StatusCode >= 300 { + return fmt.Errorf("trackers: BTN API %s failed status=%d", method, apiResp.StatusCode) + } + if err := decodeBTNAPIJSON(apiResp.Body, dest); err != nil { + return fmt.Errorf("trackers: BTN API decode %s response: %w", method, err) + } + return nil +} + +// validateBTNJSONNoDuplicateObjectNames scans one JSON value before unmarshal +// so duplicate object names cannot collapse into the last decoded value. +func validateBTNJSONNoDuplicateObjectNames(payload []byte) error { + dec := json.NewDecoder(bytes.NewReader(payload)) + dec.UseNumber() + if err := validateBTNJSONValueNoDuplicateObjectNames(dec, ""); err != nil { + return err + } + if _, err := dec.Token(); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values") + } + return fmt.Errorf("read trailing JSON token: %w", err) + } + return nil +} + +// validateBTNJSONValueNoDuplicateObjectNames consumes one JSON value and +// reports duplicate object member names with their object path. +func validateBTNJSONValueNoDuplicateObjectNames(dec *json.Decoder, path string) error { + token, err := dec.Token() + if err != nil { + return fmt.Errorf("read JSON token at %q: %w", path, err) + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + + switch delim { + case '{': + seen := map[string]struct{}{} + for dec.More() { + keyToken, err := dec.Token() + if err != nil { + return fmt.Errorf("read JSON object key at %q: %w", path, err) + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("invalid JSON object key at %q", path) + } + if _, exists := seen[key]; exists { + if path == "" { + return fmt.Errorf("duplicate JSON object key %q", key) + } + return fmt.Errorf("duplicate JSON object key %q at %q", key, path) + } + seen[key] = struct{}{} + childPath := key + if path != "" { + childPath = path + "." + key + } + if err := validateBTNJSONValueNoDuplicateObjectNames(dec, childPath); err != nil { + return err + } + } + return consumeBTNJSONDelim(dec, '}') + case '[': + index := 0 + for dec.More() { + childPath := fmt.Sprintf("%s[%d]", path, index) + if err := validateBTNJSONValueNoDuplicateObjectNames(dec, childPath); err != nil { + return err + } + index++ + } + return consumeBTNJSONDelim(dec, ']') + default: + return fmt.Errorf("unexpected JSON delimiter %q at %q", delim, path) + } +} + +// consumeBTNJSONDelim consumes and verifies a JSON closing delimiter. +func consumeBTNJSONDelim(dec *json.Decoder, want json.Delim) error { + token, err := dec.Token() + if err != nil { + return fmt.Errorf("read JSON delimiter %q: %w", want, err) + } + if delim, ok := token.(json.Delim); !ok || delim != want { + return fmt.Errorf("expected JSON delimiter %q", want) + } + return nil +} + +// resolveAndDownloadViaAPI finds the uploaded torrent through BTN's JSON-RPC +// API, validates the returned DownloadURL, and writes the fetched bencoded +// torrent to outputPath. The selected BTN torrent and group ids are returned +// so upload summaries reflect the torrent that was actually downloaded. +func resolveAndDownloadViaAPI( + ctx context.Context, + apiURL string, + apiToken string, + req trackers.PreparationInput, + groupID string, + outputPath string, +) (string, string, error) { + if strings.TrimSpace(apiToken) == "" { + return "", "", errors.New("trackers: BTN api token missing for torrent resolution") + } + if strings.TrimSpace(apiURL) == "" { + apiURL = btnAPIRPCURL + } + downloadOrigin, err := newBTNAPIDownloadOrigin(ctx, apiURL) + if err != nil { + return "", "", fmt.Errorf("trackers: BTN API download origin: %w", err) + } + releaseName := resolveUploadName(req.Meta) + filter := map[string]any{"searchstr": releaseName} + if strings.TrimSpace(groupID) != "" { + filter["group"] = groupID + } + + var response struct { + Result struct { + Torrents map[string]map[string]any `json:"torrents"` + } `json:"result"` + } + if err := callBTNAPI(ctx, apiURL, "ua-btn-upload", "getTorrentsSearch", []any{apiToken, filter, 50}, &response); err != nil { + return "", "", err + } + + selection := selectBTNAPITorrent(response.Result.Torrents, releaseName, groupID) + if selection.ID == "" { + return "", "", errors.New("trackers: BTN API did not return a matching torrent id") + } + + var downloadResult struct { + Result struct { + DownloadURL string `json:"DownloadURL"` + } `json:"result"` + } + if err := callBTNAPI(ctx, apiURL, "ua-btn-download", "getTorrentById", []any{apiToken, selection.ID}, &downloadResult); err != nil { + return "", "", err + } + + if downloadResult.Result.DownloadURL == "" { + return "", "", errors.New("trackers: BTN API did not return DownloadURL") + } + + if err := downloadOrigin.validateDownloadURL(ctx, downloadResult.Result.DownloadURL); err != nil { + return "", "", fmt.Errorf("trackers: BTN API invalid download url: %w", err) + } + + dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadResult.Result.DownloadURL, nil) + if err != nil { + return "", "", fmt.Errorf("trackers: BTN API torrent fetch request build: %w", err) + } + client := &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if err := downloadOrigin.validateDownloadURL(req.Context(), req.URL.String()); err != nil { + return err + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + }, + } + dlResp, err := client.Do(dlReq) + if err != nil { + return "", "", fmt.Errorf("trackers: BTN API torrent fetch request: %w", err) + } + defer dlResp.Body.Close() + if dlResp.StatusCode < 200 || dlResp.StatusCode >= 300 { + return "", "", fmt.Errorf("trackers: BTN API download fetch failed status=%d", dlResp.StatusCode) + } + body, err := readBTNTorrentResponseBody(dlResp.Body) + if err != nil { + return "", "", fmt.Errorf("trackers: BTN API read torrent response: %w", err) + } + if len(body) == 0 || body[0] != 'd' { + return "", "", errors.New("trackers: BTN API did not return torrent payload") + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return "", "", fmt.Errorf("trackers: BTN API create torrent output dir: %w", err) + } + if err := os.WriteFile(outputPath, body, 0o600); err != nil { + return "", "", fmt.Errorf("trackers: BTN API write torrent output: %w", err) + } + return selection.ID, selection.GroupID, nil +} + +// readBTNTorrentResponseBody rejects bodies beyond BTN's accepted torrent +// response cap before callers validate or persist the payload. +func readBTNTorrentResponseBody(r io.Reader) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, btnTorrentMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read torrent response: %w", err) + } + if len(body) > btnTorrentMaxBytes { + return nil, fmt.Errorf("torrent response exceeds %d bytes", btnTorrentMaxBytes) + } + return body, nil +} + +// loadBTNCookiesIntoJar best-effort seeds an upload client with persisted BTN +// cookies. Missing or unreadable cookies leave the caller's client unchanged. +func loadBTNCookiesIntoJar(ctx context.Context, client *http.Client, dbPath string, baseURL string) { + if client == nil || client.Jar == nil { + return + } + values, err := loadBTNCookies(ctx, dbPath) + if err != nil { + return + } + setBTNCookies(client.Jar, baseURL, values) +} + +// loadBTNCookies reads persisted BTN cookies and maps only typed not-found +// results to the BTN missing-cookie sentinel. Storage, parse, and decrypt errors +// are returned with tracker context so callers can avoid replacing valid state. +func loadBTNCookies(ctx context.Context, dbPath string) (map[string]string, error) { + values, err := cookies.LoadTrackerCookieMap(ctx, dbPath, "BTN") + if err != nil { + if errors.Is(err, cookies.ErrTrackerCookiesNotFound) { + return nil, errBTNCookiesMissing + } + return nil, fmt.Errorf("trackers: %w", err) + } + return values, nil +} + +// newBTNClientWithCookies creates a short-lived BTN client with a fresh cookie +// jar populated from the supplied stored cookie map. +func newBTNClientWithCookies(baseURL string, values map[string]string) (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("trackers: BTN create session cookie jar: %w", err) + } + setBTNCookies(jar, baseURL, values) + return &http.Client{Timeout: 45 * time.Second, Jar: jar}, nil +} + +// setBTNCookies mirrors stored BTN cookie values into jar for baseURL. Invalid +// base URLs or nil jars are ignored because callers treat cookie seeding as +// best-effort before explicit session validation. +func setBTNCookies(jar http.CookieJar, baseURL string, values map[string]string) { + if jar == nil { + return + } + parsed, err := url.Parse(baseURL) + if err != nil { + return + } + jarCookies := make([]*http.Cookie, 0, len(values)) + for name, value := range values { + // #nosec G124 -- Outbound tracker jar cookie mirrors configured BTN session values. + jarCookies = append(jarCookies, &http.Cookie{ + Name: name, + Value: value, + Domain: parsed.Hostname(), + Path: "/", + }) + } + jar.SetCookies(parsed, jarCookies) +} + +// persistBTNCookies saves cookies extracted from a caller-validated BTN client jar. +func persistBTNCookies(ctx context.Context, dbPath string, baseURL string, jar http.CookieJar) error { + values, err := btnCookiesFromJar(baseURL, jar) + if err != nil { + return err + } + if len(values) == 0 { + return errors.New("trackers: BTN login returned no usable cookies") + } + if err := cookies.SaveTrackerCookieMap(ctx, dbPath, "BTN", values); err != nil { + return fmt.Errorf("trackers: BTN save cookies: %w", err) + } + return nil +} + +// btnCookiesFromJar extracts non-empty BTN cookie names and values for baseURL +// after a caller has validated that the jar represents a usable session. +func btnCookiesFromJar(baseURL string, jar http.CookieJar) (map[string]string, error) { + if jar == nil { + return nil, errors.New("trackers: BTN login returned no cookie jar") + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("trackers: BTN parse cookie URL: %w", err) + } + out := make(map[string]string) + for _, cookie := range jar.Cookies(parsed) { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { + continue + } + out[strings.TrimSpace(cookie.Name)] = cookie.Value + } + return out, nil +} + +// resolveBTN2FACode returns a manually submitted code before falling back to +// the configured TOTP URI. +func resolveBTN2FACode(cfg config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { + if code := strings.TrimSpace(login.Code); code != "" { + return code, nil + } + return resolve2FACode(strings.TrimSpace(cfg.OTPURI)) +} + +// btnLoginNeeds2FA recognizes BTN login responses that render the manual 2FA +// challenge field instead of accepting the submitted credentials. +func btnLoginNeeds2FA(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "name=\"codenumber\"") || + strings.Contains(lower, "name='codenumber'") +} + +// btnLoginFailed recognizes explicit BTN credential or submitted-code failures +// in successful HTTP responses. +func btnLoginFailed(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "invalid login") || + strings.Contains(lower, "incorrect password") || + strings.Contains(lower, "invalid code") || + strings.Contains(lower, "incorrect code") || + strings.Contains(lower, "login failed") +} + +// btnLoggedOutPage recognizes upload-page responses that prove the session is +// logged out and safe to classify as confirmed-invalid auth. +func btnLoggedOutPage(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, " 0 { + period = parsedValue + } + } + decoder := base32.StdEncoding.WithPadding(base32.NoPadding) + secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) + if err != nil { + return "", fmt.Errorf("trackers: BTN decode otp secret: %w", err) + } + counterTime := time.Now().Unix() / int64(period) + if counterTime < 0 { + return "", errors.New("totp counter before unix epoch") + } + counter := uint64(counterTime) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + mac := hmac.New(sha1.New, secretBytes) + _, _ = mac.Write(buf) + hash := mac.Sum(nil) + offset := hash[len(hash)-1] & 0x0f + code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) + return fmt.Sprintf("%06d", code%1000000), nil +} + +func resolveBTNAPIURL(cfg config.TrackerConfig) string { + if cfg.Unknown != nil { + if raw, ok := cfg.Unknown["api_url"]; ok { + if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" { + return value + } + } + } + return btnAPIRPCURL +} + +func resolveFastTorrent(cfg config.TrackerConfig) bool { + if cfg.Unknown != nil { + if raw, ok := cfg.Unknown["fast_torrent"]; ok { + if b, ok := raw.(bool); ok { + return b + } + if s, ok := raw.(string); ok { + return strings.EqualFold(strings.TrimSpace(s), "true") || strings.TrimSpace(s) == "1" + } + } + } + return false +} + +func stripHTML(value string) string { + replacer := strings.NewReplacer("
", "\n", "
", "\n", "
", "\n") + cleaned := replacer.Replace(value) + cleaned = regexp.MustCompile(`(?s)<[^>]*>`).ReplaceAllString(cleaned, "") + return strings.TrimSpace(cleaned) +} + +// mapContainer maps local container metadata to BTN's format dropdown. Autofill +// is used only when metadata does not resolve to a BTN-supported value. +func mapContainer(meta api.UploadSubject, fields map[string]string) string { + allowed := map[string]struct{}{ + "AVI": {}, + "MKV": {}, + "VOB": {}, + "MPEG": {}, + "MP4": {}, + "ISO": {}, + "WMV": {}, + "TS": {}, + "M4V": {}, + "M2TS": {}, + "Mixed": {}, + } + container := strings.ToLower(strings.TrimSpace(meta.Container)) + mapped := map[string]string{ + "avi": "AVI", + "mkv": "MKV", + "vob": "VOB", + "mpg": "MPEG", + "mpeg": "MPEG", + "mp4": "MP4", + "iso": "ISO", + "wmv": "WMV", + "ts": "TS", + "m4v": "M4V", + "m2ts": "M2TS", + }[container] + if mapped == "" && strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + mapped = "M2TS" + } + if mapped == "" && strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + mapped = "VOB" + } + for _, candidate := range []string{mapped, fields["format"], "Mixed"} { + if _, ok := allowed[candidate]; ok { + return candidate + } + } + return "" +} + +// mapCodec maps local video codec metadata to BTN's bitrate dropdown. Autofill +// is used only when metadata does not resolve to a BTN-supported value. +func mapCodec(meta api.UploadSubject, fields map[string]string) string { + allowed := map[string]struct{}{ + "XViD": {}, + "MPEG2": {}, + "DiVX": {}, + "DVDR": {}, + "VC-1": {}, + "H.264": {}, + "H.265": {}, + "WMV": {}, + "BD": {}, + "x264-Hi10P": {}, + "VP9": {}, + "Mixed": {}, + } + videoEncode := strings.ToLower(strings.TrimSpace(meta.VideoEncode)) + videoCodec := strings.ToLower(strings.TrimSpace(meta.VideoCodec)) + bitDepth := strings.TrimSpace(meta.BitDepth) + mapped := "" + if (strings.Contains(videoEncode, "hi10") || bitDepth == "10") && + (strings.Contains(videoEncode, "x264") || strings.Contains(videoCodec, "avc") || strings.Contains(videoCodec, "h.264")) { + mapped = "x264-Hi10P" + } + if mapped == "" { + lookup := map[string]string{ + "xvid": "XViD", + "divx": "DiVX", + "mpeg-2": "MPEG2", + "mpeg2": "MPEG2", + "vc-1": "VC-1", + "wmv": "WMV", + "vp9": "VP9", + "avc": "H.264", + "h.264": "H.264", + "h264": "H.264", + "x264": "H.264", + "hevc": "H.265", + "h.265": "H.265", + "h265": "H.265", + "x265": "H.265", + } + for _, value := range []string{videoEncode, videoCodec} { + for needle, resolved := range lookup { + if strings.Contains(value, needle) { + mapped = resolved + break + } + } + if mapped != "" { + break + } + } + } + for _, candidate := range []string{mapped, fields["bitrate"], "Mixed"} { + if _, ok := allowed[candidate]; ok { + return candidate + } + } + return "" +} + +// mapSource maps local source metadata to BTN's media dropdown. Autofill is +// used only when metadata does not resolve to a BTN-supported value. +func mapSource(meta api.UploadSubject, fields map[string]string) string { + allowed := map[string]struct{}{ + "HDTV": {}, + "PDTV": {}, + "DSR": {}, + "DVDRip": {}, + "TVRip": {}, + "VHSRip": {}, + "Bluray": {}, + "BDRip": {}, + "BRRip": {}, + "DVD5": {}, + "DVD9": {}, + "HDDVD": {}, + "WEB-DL": {}, + "WEBRip": {}, + "BD5": {}, + "BD9": {}, + "BD25": {}, + "BD50": {}, + "Mixed": {}, + "Unknown": {}, + } + source := strings.ToLower(strings.TrimSpace(meta.Source)) + typeName := strings.ToUpper(strings.TrimSpace(meta.Type)) + resolution := strings.ToUpper(strings.TrimSpace(meta.Release.Resolution)) + var mapped string + switch { + case strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD"): + mapped = "DVD9" + case strings.EqualFold(strings.TrimSpace(meta.DiscType), "HDDVD"): + mapped = "HDDVD" + case typeName == "WEBDL": + mapped = "WEB-DL" + case typeName == "WEBRIP": + mapped = "WEBRip" + case typeName == "HDTV" || source == "hdtv": + mapped = "HDTV" + case typeName == "DVDRIP": + mapped = "DVDRip" + case resolution == "SD" && (source == "bluray" || source == "blu-ray"): + mapped = "BDRip" + default: + mapped = map[string]string{ + "bluray": "Bluray", + "blu-ray": "Bluray", + "bdrip": "BDRip", + "brrip": "BRRip", + "dvd5": "DVD5", + "dvd9": "DVD9", + "web-dl": "WEB-DL", + "webrip": "WEBRip", + "pdtv": "PDTV", + "dsr": "DSR", + "tvrip": "TVRip", + "vhsrip": "VHSRip", + "bd5": "BD5", + "bd9": "BD9", + "bd25": "BD25", + "bd50": "BD50", + }[source] + } + for _, candidate := range []string{mapped, fields["media"], "Unknown"} { + if _, ok := allowed[candidate]; ok { + return candidate + } + } + return "" +} + +// mapResolution returns the BTN resolution value derived from local metadata, +// falling back to BTN autofill only when metadata does not map to a BTN option. +func mapResolution(meta api.UploadSubject, fields map[string]string) string { + switch strings.ToLower(strings.TrimSpace(meta.Release.Resolution)) { + case "2160p", "4320p", "8640p", "4k", "8k": + return "2160p" + case "1080p", "1440p": + return "1080p" + case "1080i": + return "1080i" + case "720p": + return "720p" + case "sd": + return "SD" + case "portable device": + return "Portable Device" + case "mixed": + return "Mixed" + } + switch strings.TrimSpace(fields["resolution"]) { + case "SD", "720p", "1080p", "1080i", "2160p", "Portable Device", "Mixed": + return strings.TrimSpace(fields["resolution"]) + default: + return "SD" + } +} + +func applyBTNNameMapping(releaseName string, mappedCodec string, mappedSource string) string { + updated := releaseName + if mappedSource != "" { + sourcePattern := regexp.MustCompile(`(?i)\b(bluray|blu-ray|bdrip|brrip|web-dl|webrip|hdtv|dvdrip|hddvd|dvd5|dvd9|bd5|bd9|bd25|bd50)\b`) + updated = sourcePattern.ReplaceAllString(updated, mappedSource) + } + if mappedCodec != "" { + codecPatterns := map[string]*regexp.Regexp{ + "H.264": regexp.MustCompile(`(?i)\b(x264|h\.264|h264|avc)\b`), + "H.265": regexp.MustCompile(`(?i)\b(x265|h\.265|h265|hevc)\b`), + "x264-Hi10P": regexp.MustCompile(`(?i)\b(x264-hi10p|hi10p)\b`), + "XViD": regexp.MustCompile(`(?i)\b(xvid)\b`), + "DiVX": regexp.MustCompile(`(?i)\b(divx)\b`), + "MPEG2": regexp.MustCompile(`(?i)\b(mpeg-2|mpeg2)\b`), + "VC-1": regexp.MustCompile(`(?i)\b(vc-1)\b`), + "WMV": regexp.MustCompile(`(?i)\b(wmv)\b`), + "VP9": regexp.MustCompile(`(?i)\b(vp9)\b`), + } + if pattern, ok := codecPatterns[mappedCodec]; ok { + updated = pattern.ReplaceAllString(updated, mappedCodec) + } + } + return updated +} + +// resolveCountryID extracts the first available country from TVDB, TMDB, then +// IMDB metadata and returns its BTN country id. Country codes and names are +// matched only against normalized exact aliases so ambiguous inputs do not +// depend on map iteration order. +func resolveCountryID(meta api.UploadSubject) string { + var countryStr string + + // Try TVDB first (ISO 3166-1 alpha-3, lowercase) + if meta.ProviderMetadata.TVDB != nil && meta.ProviderMetadata.TVDB.OriginalCountry != "" { + countryStr = meta.ProviderMetadata.TVDB.OriginalCountry + } + + // Fall back to TMDB (ISO 3166-1 alpha-2, uppercase) + if countryStr == "" && meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.OriginCountry) > 0 { + countryStr = meta.ProviderMetadata.TMDB.OriginCountry[0] + } + + // Fall back to IMDB (full country names) + if countryStr == "" && meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Country != "" { + // IMDB can have multiple countries separated by commas, take the first one + parts := strings.Split(meta.ProviderMetadata.IMDB.Country, ",") + if len(parts) > 0 { + countryStr = strings.TrimSpace(parts[0]) + } + } + + if countryStr == "" { + return "" + } + + if id, ok := btnCountryMap[normalizeBTNCountryAlias(countryStr)]; ok { + return id + } + + return "" +} + +// resolveBTNOriginalLanguage returns provider original language in BTN +// priority order: TVDB first, then IMDb when TVDB has no value. +func resolveBTNOriginalLanguage(meta api.UploadSubject) string { + if meta.ProviderMetadata.TVDB != nil { + if language := strings.TrimSpace(meta.ProviderMetadata.TVDB.OriginalLanguage); language != "" { + return language + } + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) + } + return "" +} + +// isBTNEnglishLanguage reports whether a provider language value represents +// English and should therefore not trigger BTN's foreign flag. +func isBTNEnglishLanguage(language string) bool { + switch strings.ToLower(strings.TrimSpace(language)) { + case "en", "eng", "english": + return true + default: + return false + } +} + +// normalizeBTNCountryAlias lowercases and collapses punctuation so metadata +// country names can be compared against BTN's exact alias table. +func normalizeBTNCountryAlias(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.NewReplacer( + "&", " and ", + ".", " ", + ",", " ", + "-", " ", + "_", " ", + "'", " ", + "(", " ", + ")", " ", + ).Replace(normalized) + return strings.Join(strings.Fields(normalized), " ") +} + +// btnLookupIPAddrFunc matches net.Resolver lookups so tests can model DNS +// changes without relying on external name service. +type btnLookupIPAddrFunc func(context.Context, string) ([]net.IPAddr, error) + +// btnAPIDownloadOrigin records the resolved BTN API origin that download URLs +// may reuse when they would otherwise fail public-address validation. +type btnAPIDownloadOrigin struct { + scheme string + host string + addrs map[netip.Addr]struct{} + lookup btnLookupIPAddrFunc +} + +// resolveBTNURLAddrs resolves a URL host to unmapped IP addresses, preserving +// literal IP hosts without DNS. +func resolveBTNURLAddrs(ctx context.Context, parsed *url.URL, lookup btnLookupIPAddrFunc) ([]netip.Addr, error) { + host := strings.TrimSpace(parsed.Hostname()) + if addr, err := netip.ParseAddr(host); err == nil { + return []netip.Addr{addr.Unmap()}, nil + } + + resolved, err := lookup(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve host %q: %w", host, err) + } + addrs := make([]netip.Addr, 0, len(resolved)) + for _, item := range resolved { + if addr, ok := netip.AddrFromSlice(item.IP); ok { + addrs = append(addrs, addr.Unmap()) + } + } + if len(addrs) == 0 { + return nil, fmt.Errorf("host %q resolved to no usable addresses", host) + } + return addrs, nil +} + +// validateBTNPublicResolvedAddrs rejects private, loopback, link-local, +// multicast, unspecified, and otherwise non-global-unicast addresses. +func validateBTNPublicResolvedAddrs(host string, addrs []netip.Addr) error { + for _, addr := range addrs { + if !addr.IsValid() || !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || + addr.IsUnspecified() { + return fmt.Errorf("host %q resolved to blocked address %q", host, addr) + } + } + return nil +} + +type btnAPITorrentSelection struct { + ID string + GroupID string +} + +// selectBTNAPITorrent returns the BTN torrent that matches the uploaded +// release. It prefers an exact release-name match inside the uploaded group and +// only accepts a group-only match when that group has a single candidate. +func selectBTNAPITorrent(torrents map[string]map[string]any, releaseName string, groupID string) btnAPITorrentSelection { + expectedRelease := normalizeBTNAPIMatchValue(releaseName) + expectedGroup := strings.TrimSpace(groupID) + + ids := make([]string, 0, len(torrents)) + for id := range torrents { + if trimmed := strings.TrimSpace(id); trimmed != "" { + ids = append(ids, trimmed) + } + } + sortBTNAPITorrentIDs(ids) + + groupMatches := make([]string, 0, len(ids)) + for _, id := range ids { + torrentData := torrents[id] + if expectedGroup != "" { + torrentGroup := btnAPITorrentGroupID(torrentData) + if strings.TrimSpace(torrentGroup) != expectedGroup { + continue + } + } + groupMatches = append(groupMatches, id) + if expectedRelease != "" && btnAPITorrentMatchesRelease(torrentData, expectedRelease) { + return btnAPITorrentSelection{ID: id, GroupID: btnAPITorrentGroupID(torrentData)} + } + } + if len(groupMatches) == 1 { + return btnAPITorrentSelection{ID: groupMatches[0], GroupID: btnAPITorrentGroupID(torrents[groupMatches[0]])} + } + return btnAPITorrentSelection{} +} + +// btnAPITorrentGroupID extracts BTN's group id from known API field spellings. +func btnAPITorrentGroupID(torrentData map[string]any) string { + return metautil.FirstNonEmptyTrimmed( + btnAPIStringField(torrentData, "GroupID"), + btnAPIStringField(torrentData, "groupId"), + btnAPIStringField(torrentData, "GroupId"), + btnAPIStringField(torrentData, "group_id"), + ) +} + +// sortBTNAPITorrentIDs orders API result ids deterministically, newest numeric +// ids first when all compared ids are numeric. +func sortBTNAPITorrentIDs(ids []string) { + sort.Slice(ids, func(i, j int) bool { + left, leftErr := strconv.Atoi(ids[i]) + right, rightErr := strconv.Atoi(ids[j]) + if leftErr == nil && rightErr == nil { + return left > right + } + return ids[i] > ids[j] + }) +} + +// btnAPITorrentMatchesRelease reports whether a known BTN API name field is an +// exact normalized match for the release name we uploaded. +func btnAPITorrentMatchesRelease(torrentData map[string]any, expectedRelease string) bool { + for _, field := range []string{"ReleaseName", "releaseName", "TorrentName", "torrentName", "Name", "name", "Release", "release"} { + if normalizeBTNAPIMatchValue(btnAPIStringField(torrentData, field)) == expectedRelease { + return true + } + } + return false +} + +// btnAPIStringField returns an API field as a trimmed string. Missing or null +// fields produce an empty value; numeric fields keep their decimal form. +func btnAPIStringField(data map[string]any, field string) string { + if data == nil { + return "" + } + value, ok := data[field] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +// normalizeBTNAPIMatchValue canonicalizes BTN API comparison values for exact, +// case-insensitive release-name matching. +func normalizeBTNAPIMatchValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// validateBTNAPIDownloadURL applies public-address validation to a BTN API +// DownloadURL, but permits a same-origin private URL when the caller explicitly +// configured the API endpoint to that pinned private address. The pinned-origin +// exception keeps local test servers usable without allowing arbitrary private +// redirects or same-host DNS rebinding. +func validateBTNAPIDownloadURL(ctx context.Context, apiURL string, rawURL string) error { + origin, err := newBTNAPIDownloadOrigin(ctx, apiURL) + if err != nil { + return err + } + return origin.validateDownloadURL(ctx, rawURL) +} + +func newBTNAPIDownloadOrigin(ctx context.Context, apiURL string) (*btnAPIDownloadOrigin, error) { + return newBTNAPIDownloadOriginWithLookup(ctx, apiURL, net.DefaultResolver.LookupIPAddr) +} + +// newBTNAPIDownloadOriginWithLookup parses and pins the API URL's scheme, host, +// and current resolved addresses for later download redirect validation. +func newBTNAPIDownloadOriginWithLookup(ctx context.Context, apiURL string, lookup btnLookupIPAddrFunc) (*btnAPIDownloadOrigin, error) { + parsed, err := url.Parse(strings.TrimSpace(apiURL)) + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return nil, fmt.Errorf("unsupported scheme %q", parsed.Scheme) + } + if strings.TrimSpace(parsed.Hostname()) == "" { + return nil, errors.New("missing host") + } + if strings.Contains(parsed.Hostname(), "%") { + return nil, fmt.Errorf("blocked private host %q", parsed.Hostname()) + } + addrs, err := resolveBTNURLAddrs(ctx, parsed, lookup) + if err != nil { + return nil, err + } + pinned := make(map[netip.Addr]struct{}, len(addrs)) + for _, addr := range addrs { + pinned[addr] = struct{}{} + } + return &btnAPIDownloadOrigin{ + scheme: scheme, + host: parsed.Host, + addrs: pinned, + lookup: lookup, + }, nil +} + +// validateDownloadURL accepts public HTTP(S) destinations and otherwise only +// permits same-origin destinations that still resolve to the pinned API address. +func (origin *btnAPIDownloadOrigin) validateDownloadURL(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("unsupported scheme %q", parsed.Scheme) + } + host := strings.TrimSpace(parsed.Hostname()) + if host == "" { + return errors.New("missing host") + } + addrs, err := resolveBTNURLAddrs(ctx, parsed, origin.lookup) + if err != nil { + return err + } + publicErr := validateBTNPublicResolvedAddrs(host, addrs) + lowerHost := strings.ToLower(host) + if publicErr == nil && lowerHost != "localhost" && !strings.HasSuffix(lowerHost, ".localhost") && !strings.Contains(lowerHost, "%") { + return nil + } + + if !origin.sameOrigin(parsed) { + if publicErr != nil { + return publicErr + } + return fmt.Errorf("blocked private host %q", host) + } + + for _, addr := range addrs { + if _, ok := origin.addrs[addr]; !ok { + return fmt.Errorf("blocked address %q not pinned to BTN API origin", addr) + } + } + + return nil +} + +// sameOrigin reports whether a URL has the pinned API origin's scheme and host. +func (origin *btnAPIDownloadOrigin) sameOrigin(parsed *url.URL) bool { + if origin == nil || parsed == nil { + return false + } + return strings.EqualFold(origin.scheme, parsed.Scheme) && + strings.EqualFold(origin.host, parsed.Host) && + origin.host != "" +} + +// checkBTNSeasonPackReservation enforces BTN's 2-hour internal season-pack +// reservation using the same canonical season source as the final upload +// payload, including TVDB/IMDb translated episode metadata. +func checkBTNSeasonPackReservation(ctx context.Context, uploadCtx uploadContext, req trackers.PreparationInput) error { + if resolveUploadType(req.Meta) != "Season" { + return nil + } + // The 2-hour reservation ONLY applies to season packs made of internal releases. + if !isBTNInternalGroup(req.Meta) { + return nil + } + + tvdbID := req.Meta.Identity.TVDBID + if tvdbID == 0 { + return nil + } + + group := strings.TrimPrefix(req.Meta.Tag, "-") + if group == "" { + return nil + } + + filter := map[string]any{ + "tvdb": strconv.Itoa(tvdbID), + "category": "Episode", + "group": group, + } + + // We only need the most recent episodes to check the 2-hour window + torrentsMap, err := btnAPISearchTorrents(ctx, uploadCtx.apiURL, uploadCtx.apiToken, filter, 50) + if err != nil { + return err + } + + seasonPrefix := "" + season, _ := resolveBTNTVSeasonEpisode(req.Meta) + if season > 0 { + seasonPrefix = fmt.Sprintf("S%02dE", season) + } + + var newestInternal time.Time + for _, t := range torrentsMap { + name, _ := t["ReleaseName"].(string) + if seasonPrefix != "" && !strings.Contains(strings.ToUpper(name), seasonPrefix) { + continue + } + + tTime := parseBTNTimestamp(t["Time"]) + if tTime.After(newestInternal) { + newestInternal = tTime + } + } + + if !newestInternal.IsZero() && time.Since(newestInternal) < 2*time.Hour { + return errors.New("trackers: BTN 2-hour reservation period for internal season packs has not expired") + } + + return nil +} + +func btnAPISearchTorrents(ctx context.Context, apiURL, apiToken string, filter map[string]any, limit int) (map[string]map[string]any, error) { + var response struct { + Result struct { + Torrents json.RawMessage `json:"torrents"` + } `json:"result"` + } + if err := callBTNAPI(ctx, apiURL, "ua-btn-upload-check", "getTorrentsSearch", []any{apiToken, filter, limit}, &response); err != nil { + return nil, err + } + + if len(response.Result.Torrents) == 0 || string(response.Result.Torrents) == "false" || string(response.Result.Torrents) == "[]" { + return nil, nil + } + + var torrentsMap map[string]map[string]any + if err := json.Unmarshal(response.Result.Torrents, &torrentsMap); err != nil { + return nil, fmt.Errorf("trackers: BTN API parse torrents search response: %s", redaction.RedactValue(err.Error(), nil)) + } + return torrentsMap, nil +} + +func parseBTNTimestamp(val any) time.Time { + var epoch int64 + switch v := val.(type) { + case string: + epoch, _ = strconv.ParseInt(v, 10, 64) + case float64: + epoch = int64(v) + } + if epoch > 0 { + return time.Unix(epoch, 0) + } + return time.Time{} +} diff --git a/internal/trackers/impl/btn/upload_integration_test.go b/internal/trackers/impl/standalone/btn/upload_integration_test.go similarity index 92% rename from internal/trackers/impl/btn/upload_integration_test.go rename to internal/trackers/impl/standalone/btn/upload_integration_test.go index 55f4f001..48da11a6 100644 --- a/internal/trackers/impl/btn/upload_integration_test.go +++ b/internal/trackers/impl/standalone/btn/upload_integration_test.go @@ -131,7 +131,11 @@ func TestBTNUploadEndToEndSuccess(t *testing.T) { switch { case r.URL.Path == "/login.php" && r.Method == http.MethodPost: loginCalls.Add(1) - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) case r.URL.Path == "/upload.php" && r.Method == http.MethodGet: @@ -201,9 +205,9 @@ func TestBTNUploadEndToEndSuccess(t *testing.T) { mediaInfoText := "General\nFormat: Matroska\nVideo\nCodec: H.265" mediaInfoPath := writeBTNTestMediaInfo(t, tempDir, mediaInfoText) - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "BTN", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, MediaInfoTextPath: mediaInfoPath, @@ -218,7 +222,7 @@ func TestBTNUploadEndToEndSuccess(t *testing.T) { EpisodeTitle: "Episode One", EpisodeOverview: "Overview", TVDBAiredDate: "2025-01-01", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", }, Release: api.ReleaseInfo{ @@ -230,19 +234,18 @@ func TestBTNUploadEndToEndSuccess(t *testing.T) { Tag: "GRP", }, TrackerConfig: config.TrackerConfig{ - URL: server.URL, Username: "user", Password: "pass", }, - AppConfig: config.Config{ + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ "BTN": {APIKey: strings.Repeat("x", 30)}, }}, - }, + }), } - summary, err := upload(context.Background(), req) + summary, err := uploadAt(context.Background(), req, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("upload failed: %v", err) @@ -320,7 +323,11 @@ func TestBTNUploadAnnounceURLWritesTorrentArtifact(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/login.php" && r.Method == http.MethodPost: - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) case r.URL.Path == "/upload.php" && r.Method == http.MethodGet: @@ -354,13 +361,13 @@ func TestBTNUploadAnnounceURLWritesTorrentArtifact(t *testing.T) { })) defer server.Close() - req := newBTNUploadTestRequest(t, server.URL) + req := newBTNUploadTestRequest(t) torrentPath := filepath.Join(t.TempDir(), "input.torrent") writeBTNTestTorrent(t, torrentPath) req.Meta.TorrentPath = torrentPath req.TrackerConfig.AnnounceURL = "https://tracker.btn.example/announce/passkey" - summary, err := upload(context.Background(), req) + summary, err := uploadAt(context.Background(), req, server.URL) if err != nil { t.Fatalf("upload failed: %v", err) } @@ -453,9 +460,9 @@ func TestBTNUploadUsesValidImportedCookiesWithoutCredentials(t *testing.T) { t.Fatalf("write torrent: %v", err) } - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "BTN", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", @@ -466,19 +473,23 @@ func TestBTNUploadUsesValidImportedCookiesWithoutCredentials(t *testing.T) { VideoCodec: "HEVC", SeasonInt: 1, EpisodeInt: 1, - ExternalIDs: api.ExternalIDs{Category: "TV"}, - Release: api.ReleaseInfo{Resolution: "1080p", Season: 1, Episode: 1}, + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{ + Resolution: "1080p", + Season: 1, + Episode: 1, + }, }, - TrackerConfig: config.TrackerConfig{URL: server.URL}, - AppConfig: config.Config{ + TrackerConfig: config.TrackerConfig{}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ "BTN": {APIKey: strings.Repeat("x", 30)}, }}, - }, + }), } - summary, err := upload(context.Background(), req) + summary, err := uploadAt(context.Background(), req, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("upload failed: %v", err) @@ -502,7 +513,11 @@ func TestBTNUploadStoredCookieLoadErrorPreventsLogin(t *testing.T) { switch r.URL.Path { case "/login.php": loginCalls.Add(1) - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) _, _ = w.Write([]byte("ok")) case "/upload.php": _, _ = w.Write([]byte(`
`)) @@ -518,7 +533,6 @@ func TestBTNUploadStoredCookieLoadErrorPreventsLogin(t *testing.T) { } _, err := ensureBTNUploadSession(context.Background(), config.TrackerConfig{ - URL: server.URL, Username: "user", Password: "pass", }, dbPath, uploadContext{baseURL: server.URL}) @@ -575,8 +589,7 @@ func TestBTNDryRunRequiresUploadAuthPrerequisites(t *testing.T) { })) defer server.Close() cookieReq := newBTNDryRunTestRequest(t, cookieDBPath) - cookieReq.TrackerConfig.URL = server.URL - entry, err = buildUploadDryRun(context.Background(), cookieReq) + entry, err = buildUploadDryRunAt(context.Background(), cookieReq, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("BuildUploadDryRun with stored cookies: %v", err) @@ -611,9 +624,8 @@ func TestBTNDryRunRejectsStaleStoredCookiesWithoutCredentials(t *testing.T) { defer server.Close() req := newBTNDryRunTestRequest(t, dbPath) - req.TrackerConfig.URL = server.URL - _, err := buildUploadDryRun(context.Background(), req) + _, err := buildUploadDryRunAt(context.Background(), req, server.URL) handlerErrs.Check() if !errors.Is(err, errBTNSessionConfirmedInvalid) { t.Fatalf("expected stale stored cookies to block dry-run readiness, got %v", err) @@ -643,7 +655,7 @@ func TestBTNDryRunBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { } } -func TestBTNDryRunDebugRunsBTNAutofillAndReportsBothPayloads(t *testing.T) { +func TestBTNExplicitDryRunRunsAutofillAndReportsBothPayloads(t *testing.T) { t.Parallel() dbPath := newBTNAuthDB(t) @@ -699,20 +711,20 @@ func TestBTNDryRunDebugRunsBTNAutofillAndReportsBothPayloads(t *testing.T) { defer server.Close() req := newBTNDryRunTestRequest(t, dbPath) - req.TrackerConfig.URL = server.URL req.Meta.MediaInfoTextPath = writeBTNTestMediaInfo(t, filepath.Dir(req.Meta.SourcePath), "General\nFormat: Matroska") - req.Meta.Options.Debug = true + req.Intent = trackers.PreparationIntentDryRun req.Meta.TVPack = true req.Meta.EpisodeInt = 0 req.Meta.ReleaseName = "Example.Show.S05.1080p.WEB-DL.x265-GRP" req.Meta.SeasonInt = 3 - req.Meta.ExternalMetadata.TVDB = &api.TVDBMetadata{ + req.Meta.Identity.TVDBID = 12345 + req.Meta.ProviderMetadata.TVDB = &api.TVDBMetadata{ TVDBID: 12345, EpisodeSeason: 5, OriginalLanguage: "en", } - entry, err := buildUploadDryRun(context.Background(), req) + entry, err := buildUploadDryRunAt(context.Background(), req, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("BuildUploadDryRun: %v", err) @@ -773,7 +785,6 @@ func TestBTNUploadBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { logger := &captureBTNLogger{} req := newBTNDryRunTestRequest(t, newBTNAuthDB(t)) - req.TrackerConfig.URL = server.URL req.TrackerConfig.Username = "user" req.TrackerConfig.Password = "pass" req.Logger = logger @@ -782,7 +793,7 @@ func TestBTNUploadBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { req.Meta.Release.Season = 1 req.Meta.Release.Episode = 1 - _, err := upload(context.Background(), req) + _, err := uploadAt(context.Background(), req, server.URL) if err == nil { t.Fatal("expected canonical TV metadata gap to block upload") } @@ -820,11 +831,10 @@ func TestResolveSessionForTrackerAuthLoginStorageErrorPreventsLogin(t *testing.T t.Fatalf("write corrupt db: %v", err) } - err := ResolveSessionForTrackerAuthLogin(context.Background(), config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(context.Background(), config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) if err == nil { t.Fatal("expected stored cookie load error") } @@ -844,7 +854,11 @@ func TestResolveSessionForTrackerAuthLoginDecryptErrorPreventsPersistence(t *tes switch r.URL.Path { case "/login.php": loginCalls.Add(1) - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) _, _ = w.Write([]byte("ok")) case "/upload.php": _, _ = w.Write([]byte(`
`)) @@ -861,11 +875,10 @@ func TestResolveSessionForTrackerAuthLoginDecryptErrorPreventsPersistence(t *tes } corruptBTNCookieAuthTag(t, dbPath) - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) if err == nil { t.Fatal("expected stored cookie decrypt error") } @@ -901,7 +914,18 @@ func TestBTNPrepareUploadDataFailsOnAutofillFailure(t *testing.T) { client: server.Client(), } - req := trackers.UploadRequest{Meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}, ReleaseName: "Show.S01E01", Type: "WEBDL", Source: "WEB-DL", Container: "MKV", VideoEncode: "x265", VideoCodec: "HEVC", SeasonInt: 1, EpisodeInt: 1, Release: api.ReleaseInfo{Resolution: "1080p"}}} + req := trackers.PreparationInput{Meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + ReleaseName: "Show.S01E01", + Type: "WEBDL", + Source: "WEB-DL", + Container: "MKV", + VideoEncode: "x265", + VideoCodec: "HEVC", + SeasonInt: 1, + EpisodeInt: 1, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }} _, err := prepareUploadData(context.Background(), req, uploadCtx) if err == nil { t.Fatalf("expected autofill validation error") @@ -955,9 +979,9 @@ func TestBTNPrepareUploadDataUsesEpisodeIntForTVDBAutoTitle(t *testing.T) { client: server.Client(), } logger := &captureBTNLogger{} - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Logger: logger, - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Example.Show.S02E07.1080p.WEB-DL.x265-GRP", Type: "WEBDL", Source: "WEB-DL", @@ -969,8 +993,8 @@ func TestBTNPrepareUploadDataUsesEpisodeIntForTVDBAutoTitle(t *testing.T) { EpisodeTitle: "Episode Seven", EpisodeOverview: "Overview", DescriptionOverride: "Description", - ExternalIDs: api.ExternalIDs{Category: "TV"}, - ExternalMetadata: api.ExternalMetadata{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 12345}, + ProviderMetadata: api.SourceScopedMetadata{ TVDB: &api.TVDBMetadata{ TVDBID: 12345, Name: "Native Show", @@ -1107,8 +1131,8 @@ func TestBTNPrepareUploadDataUsesSeasonIntForTVDBSeasonPack(t *testing.T) { uploadURL: server.URL + "/upload.php", client: server.Client(), } - req := trackers.UploadRequest{ - Meta: api.PreparedMetadata{ + req := trackers.PreparationInput{ + Meta: api.UploadSubject{ ReleaseName: "Example.Show.S03.1080p.WEB-DL.x265-GRP", Type: "WEBDL", Source: "WEB-DL", @@ -1118,9 +1142,15 @@ func TestBTNPrepareUploadDataUsesSeasonIntForTVDBSeasonPack(t *testing.T) { SeasonInt: 3, TVPack: true, DescriptionOverride: "Description", - ExternalIDs: api.ExternalIDs{Category: "TV"}, - ExternalMetadata: api.ExternalMetadata{ - TVDB: &api.TVDBMetadata{TVDBID: 12345, OriginalLanguage: "en", EpisodeSeason: 5, Genres: "Drama, Science-Fiction", Poster: "https://img.example/tvdb.jpg"}, + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 12345}, + ProviderMetadata: api.SourceScopedMetadata{ + TVDB: &api.TVDBMetadata{ + TVDBID: 12345, + OriginalLanguage: "en", + EpisodeSeason: 5, + Genres: "Drama, Science-Fiction", + Poster: "https://img.example/tvdb.jpg", + }, }, Release: api.ReleaseInfo{ Resolution: "1080p", @@ -1176,16 +1206,16 @@ func TestBTNPrepareUploadDataBlocksMissingCanonicalTVMetadataBeforeAutofill(t *t uploadURL: server.URL + "/upload.php", client: server.Client(), } - req := trackers.UploadRequest{ - Meta: api.PreparedMetadata{ + req := trackers.PreparationInput{ + Meta: api.UploadSubject{ ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", Type: "WEBDL", Source: "WEB-DL", Container: "MKV", VideoEncode: "x265", VideoCodec: "HEVC", - ExternalIDs: api.ExternalIDs{Category: "TV"}, - ExternalMetadata: api.ExternalMetadata{ + Identity: api.ExternalIdentity{Category: "TV"}, + ProviderMetadata: api.SourceScopedMetadata{ TVDB: &api.TVDBMetadata{TVDBID: 12345, OriginalLanguage: "en"}, }, Release: api.ReleaseInfo{ @@ -1217,7 +1247,11 @@ func TestBTNUploadCredentialLoginDoesNotPersistInvalidSession(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/login.php": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) _, _ = w.Write([]byte("ok")) case "/upload.php": _, _ = w.Write([]byte(`
`)) @@ -1228,7 +1262,6 @@ func TestBTNUploadCredentialLoginDoesNotPersistInvalidSession(t *testing.T) { t.Cleanup(server.Close) _, err := ensureBTNUploadSession(ctx, config.TrackerConfig{ - URL: server.URL, Username: "user", Password: "pass", }, dbPath, uploadContext{baseURL: server.URL}) @@ -1401,7 +1434,11 @@ func TestResolveSessionForTrackerAuthLoginPersistsCookies(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/login.php": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) _, _ = w.Write([]byte("ok")) case "/upload.php": if got := r.Header.Get("Cookie"); !strings.Contains(got, "session=new") { @@ -1416,11 +1453,10 @@ func TestResolveSessionForTrackerAuthLoginPersistsCookies(t *testing.T) { })) t.Cleanup(server.Close) - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) @@ -1443,7 +1479,11 @@ func TestResolveSessionForTrackerAuthLoginIgnoresIncidentalTwoFactorText(t *test server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/login.php": - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) _, _ = w.Write([]byte(`

Keep your two-factor recovery codes safe.

`)) case "/upload.php": if got := r.Header.Get("Cookie"); !strings.Contains(got, "session=new") { @@ -1458,11 +1498,10 @@ func TestResolveSessionForTrackerAuthLoginIgnoresIncidentalTwoFactorText(t *test })) t.Cleanup(server.Close) - err := ResolveSessionForTrackerAuthLogin(ctx, config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) handlerErrs.Check() if err != nil { t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) @@ -1489,11 +1528,10 @@ func TestResolveSessionForTrackerAuthLoginRequiresManual2FA(t *testing.T) { })) t.Cleanup(server.Close) - err := ResolveSessionForTrackerAuthLogin(context.Background(), config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(context.Background(), config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{}) + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) if err == nil || !strings.Contains(err.Error(), "2FA required") { t.Fatalf("expected 2FA required error, got %v", err) } @@ -1512,11 +1550,10 @@ func TestResolveSessionForTrackerAuthLoginMarksSubmitted2FARejected(t *testing.T })) t.Cleanup(server.Close) - err := ResolveSessionForTrackerAuthLogin(context.Background(), config.TrackerConfig{ - URL: server.URL, + err := resolveSessionForTrackerAuthLoginAt(context.Background(), config.TrackerConfig{ Username: "user", Password: "pass", - }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}) + }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}, server.URL) if !errors.Is(err, ErrSubmitted2FARejected) { t.Fatalf("expected submitted 2FA rejection marker, got %v", err) } @@ -1532,7 +1569,11 @@ func TestBTNUploadFallsBackToAPIResolution(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/login.php" && r.Method == http.MethodPost: - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) case r.URL.Path == "/upload.php" && r.Method == http.MethodGet: @@ -1597,9 +1638,9 @@ func TestBTNUploadFallsBackToAPIResolution(t *testing.T) { t.Fatalf("write torrent: %v", err) } - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "BTN", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", @@ -1614,7 +1655,7 @@ func TestBTNUploadFallsBackToAPIResolution(t *testing.T) { EpisodeOverview: "Overview", TVDBAiredDate: "2025-01-01", DescriptionOverride: "[b]Test[/b] description", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", }, Release: api.ReleaseInfo{ @@ -1625,22 +1666,21 @@ func TestBTNUploadFallsBackToAPIResolution(t *testing.T) { Tag: "GRP", }, TrackerConfig: config.TrackerConfig{ - URL: server.URL, Username: "user", Password: "pass", Unknown: map[string]any{ "api_url": server.URL + "/rpc", }, }, - AppConfig: config.Config{ + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ "BTN": {APIKey: strings.Repeat("x", 30)}, }}, - }, + }), } - summary, err := upload(context.Background(), req) + summary, err := uploadAt(context.Background(), req, server.URL) if err != nil { t.Fatalf("upload failed: %v", err) } @@ -1699,12 +1739,12 @@ func TestBTNSeasonPackReservationFailsClosedOnMalformedAPISearch(t *testing.T) { })) defer server.Close() - req := trackers.UploadRequest{ - Meta: api.PreparedMetadata{ + req := trackers.PreparationInput{ + Meta: api.UploadSubject{ TVPack: true, SeasonInt: 1, Tag: "NTb", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ TVDBID: 123456, Category: "TV", }, @@ -1750,16 +1790,16 @@ func TestBTNSeasonPackReservationUsesTranslatedTVDBSeason(t *testing.T) { })) defer server.Close() - req := trackers.UploadRequest{ - Meta: api.PreparedMetadata{ + req := trackers.PreparationInput{ + Meta: api.UploadSubject{ TVPack: true, SeasonInt: 3, Tag: "NTb", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ TVDBID: 123456, Category: "TV", }, - ExternalMetadata: api.ExternalMetadata{ + ProviderMetadata: api.SourceScopedMetadata{ TVDB: &api.TVDBMetadata{EpisodeSeason: 5}, }, }, @@ -1787,7 +1827,11 @@ func TestBTNUploadFollowsIntermediateDetailPage(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/login.php" && r.Method == http.MethodPost: - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) case r.URL.Path == "/upload.php" && r.Method == http.MethodGet: @@ -1826,8 +1870,8 @@ func TestBTNUploadFollowsIntermediateDetailPage(t *testing.T) { })) defer server.Close() - req := newBTNUploadTestRequest(t, server.URL) - summary, err := upload(context.Background(), req) + req := newBTNUploadTestRequest(t) + summary, err := uploadAt(context.Background(), req, server.URL) if err != nil { t.Fatalf("upload failed: %v", err) } @@ -1864,7 +1908,11 @@ func TestBTNUploadIntermediateFailureFallsBackToAPI(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/login.php" && r.Method == http.MethodPost: - http.SetCookie(w, &http.Cookie{Name: "session", Value: "new", Path: "/"}) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) case r.URL.Path == "/upload.php" && r.Method == http.MethodGet: @@ -1914,9 +1962,9 @@ func TestBTNUploadIntermediateFailureFallsBackToAPI(t *testing.T) { })) defer server.Close() - req := newBTNUploadTestRequest(t, server.URL) + req := newBTNUploadTestRequest(t) req.TrackerConfig.Unknown = map[string]any{"api_url": server.URL + "/rpc"} - summary, err := upload(context.Background(), req) + summary, err := uploadAt(context.Background(), req, server.URL) if err != nil { t.Fatalf("upload failed: %v", err) } @@ -1934,7 +1982,7 @@ func TestBTNUploadIntermediateFailureFallsBackToAPI(t *testing.T) { } } -func newBTNUploadTestRequest(t *testing.T, serverURL string) trackers.UploadRequest { +func newBTNUploadTestRequest(t *testing.T) trackers.PreparationInput { t.Helper() tempDir := t.TempDir() @@ -1948,9 +1996,9 @@ func newBTNUploadTestRequest(t *testing.T, serverURL string) trackers.UploadRequ t.Fatalf("write torrent: %v", err) } - return trackers.UploadRequest{ + return trackers.PreparationInput{ Tracker: "BTN", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", @@ -1965,7 +2013,7 @@ func newBTNUploadTestRequest(t *testing.T, serverURL string) trackers.UploadRequ EpisodeOverview: "Overview", TVDBAiredDate: "2025-01-01", DescriptionOverride: "[b]Test[/b] description", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", }, Release: api.ReleaseInfo{ @@ -1976,16 +2024,15 @@ func newBTNUploadTestRequest(t *testing.T, serverURL string) trackers.UploadRequ Tag: "GRP", }, TrackerConfig: config.TrackerConfig{ - URL: serverURL, Username: "user", Password: "pass", }, - AppConfig: config.Config{ + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ "BTN": {APIKey: strings.Repeat("x", 30)}, }}, - }, + }), } } @@ -2078,7 +2125,7 @@ func corruptBTNCookieAuthTag(t *testing.T, dbPath string) { // newBTNDryRunTestRequest returns a minimal TV upload request with a BTN API key. // Callers add credentials or stored cookies to exercise dry-run auth gating. -func newBTNDryRunTestRequest(t *testing.T, dbPath string) trackers.UploadRequest { +func newBTNDryRunTestRequest(t *testing.T, dbPath string) trackers.PreparationInput { t.Helper() tempDir := t.TempDir() @@ -2090,9 +2137,9 @@ func newBTNDryRunTestRequest(t *testing.T, dbPath string) trackers.UploadRequest if err := os.WriteFile(torrentPath, []byte("d8:announce13:https://x.ee"), 0o600); err != nil { t.Fatalf("write torrent: %v", err) } - return trackers.UploadRequest{ + return trackers.PreparationInput{ Tracker: "BTN", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, ReleaseName: "Example.Show.S01E01.1080p.WEB-DL.x265-GRP", @@ -2107,7 +2154,7 @@ func newBTNDryRunTestRequest(t *testing.T, dbPath string) trackers.UploadRequest EpisodeOverview: "Overview", TVDBAiredDate: "2025-01-01", DescriptionOverride: "[b]Test[/b] description", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", }, Release: api.ReleaseInfo{ @@ -2117,11 +2164,11 @@ func newBTNDryRunTestRequest(t *testing.T, dbPath string) trackers.UploadRequest }, }, TrackerConfig: config.TrackerConfig{}, - AppConfig: config.Config{ + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ "BTN": {APIKey: strings.Repeat("x", 30)}, }}, - }, + }), } } diff --git a/internal/trackers/impl/standalone/czt/definition.go b/internal/trackers/impl/standalone/czt/definition.go new file mode 100644 index 00000000..c234cfa0 --- /dev/null +++ b/internal/trackers/impl/standalone/czt/definition.go @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package czt + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +// prepareDescription renders the CZTeam user description from caller-supplied +// prepared assets, using an empty asset set when none are available. +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets := trackers.DescriptionAssets{} + if req.Assets != nil { + assets = *req.Assets + } else { + resolved, err := trackers.PreparedDescriptionAssets(req.Assets) + if err == nil { + assets = resolved + } + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: descGroup, Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/czt/dupe.go b/internal/trackers/impl/standalone/czt/dupe.go new file mode 100644 index 00000000..260bb870 --- /dev/null +++ b/internal/trackers/impl/standalone/czt/dupe.go @@ -0,0 +1,269 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package czt + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +const cztDefaultBaseURL = "https://czteam.me" + +// cztHandler searches CZTeam for existing releases via its JSON search API: +// +// GET {base}/api.php?action=search-torrents&type=name&query=...&passkey=<16-hex> +// +// The endpoint authenticates with the user's passkey (the same one used by +// upload, announce, and download) and normally returns torrent objects either +// as a bare array or under a small response wrapper. +type cztHandler struct { + cfg config.Config + http *http.Client + logger api.Logger + baseURL string +} + +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return cztHandler{ + cfg: cfg, + http: httpClient, + logger: logger, + baseURL: cztDefaultBaseURL, + } +} + +// Search queries CZTeam by the most specific prepared release name available. +func (h cztHandler) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + tracker, ok := cztTrackerConfig(h.cfg) + if !ok { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing passkey for tracker", nil) + } + passkey := strings.TrimSpace(tracker.Passkey) + if passkey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing passkey for tracker", nil) + } + + query := cztSearchQuery(meta) + if query == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing title for CZT dupe search", nil) + } + + base := strings.TrimRight(strings.TrimSpace(h.baseURL), "/") + if base == "" { + base = cztDefaultBaseURL + } + + params := url.Values{} + params.Set("action", "search-torrents") + params.Set("type", "name") + params.Set("query", query) + params.Set("passkey", passkey) + params.Set("incldead", "1") + + status, payload, err := cztJSONGet(ctx, h.http, base+"/api.php", params) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return dupe.Failed(dupe.FailureRequest, "CZT search canceled", ctxErr) + } + return dupe.Failed(dupe.FailureRequest, "CZT search failed", redactedError(err)) + } + if err := ctx.Err(); err != nil { + return dupe.Failed(dupe.FailureRequest, "CZT search canceled", err) + } + if status < 200 || status >= 300 { + return dupe.Failed(dupe.FailureResponseStatus, "CZT search failed", fmt.Errorf("HTTP status %d", status)) + } + if payload == nil { + return dupe.Failed(dupe.FailureResponseParse, "CZT search failed: empty response", errors.New("CZT search failed: empty response")) + } + + items, ok := cztResultItems(payload) + if !ok { + return dupe.Failed( + dupe.FailureResponseParse, + "CZT search failed: unexpected response shape", + errors.New("CZT search failed: unexpected response shape"), + ) + } + + entries := make([]api.DupeEntry, 0, len(items)) + for _, raw := range items { + if err := ctx.Err(); err != nil { + return dupe.Failed(dupe.FailureRequest, "CZT search canceled", err) + } + item, ok := raw.(map[string]any) + if !ok { + continue + } + name := strings.TrimSpace(stringFromAny(firstValue(item, + "name", "Name", + "torrent_name", "torrentName", "torrentname", + "release_name", "releaseName", + "title", "Title", + "filename", "fileName", + ))) + if name == "" { + continue + } + entry := api.DupeEntry{ + Name: name, + ID: stringFromAny(firstValue(item, "id", "ID", "torrent_id", "torrentId", "torrentid")), + Link: stringFromAny(firstValue(item, "url", "URL", "details_url", "detailsUrl", "link")), + } + if size := intFromAny(firstValue(item, "size", "Size", "size_bytes", "sizeBytes")); size > 0 { + entry.SizeKnown = true + entry.SizeBytes = size + } + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +// cztSearchQuery prefers exact upload/client names before media titles because +// CZTeam's name search matches torrent release names. +func cztSearchQuery(meta api.DuplicateSubject) string { + return metautil.FirstNonEmptyTrimmed( + meta.SceneName, + meta.ReleaseName, + meta.Release.Title, + meta.Filename, + ) +} + +// cztResultItems extracts torrent rows from the CZTeam search response. Auth or +// validation objects without a recognizable result container return false so +// callers fail the dupe check instead of treating the search as empty. +func cztResultItems(payload any) ([]any, bool) { + if items, ok := payload.([]any); ok { + return items, true + } + obj, ok := payload.(map[string]any) + if !ok { + return nil, false + } + for _, key := range []string{"data", "results", "torrents", "response"} { + value, ok := obj[key] + if !ok { + continue + } + if items, ok := value.([]any); ok { + return items, true + } + if nested, ok := cztResultItems(value); ok { + return nested, true + } + } + if values, ok := cztObjectItems(obj); ok { + return values, true + } + return nil, false +} + +// cztObjectItems accepts API shapes that key torrent rows by id. Mixed scalar +// fields are rejected because those shapes are usually error/status payloads. +func cztObjectItems(obj map[string]any) ([]any, bool) { + if len(obj) == 0 { + return nil, false + } + items := make([]any, 0, len(obj)) + for _, value := range obj { + if _, ok := value.(map[string]any); !ok { + return nil, false + } + items = append(items, value) + } + return items, true +} + +// redactedError returns an error whose text has passed through the repository +// redactor, preventing passkeys embedded in transport errors from surfacing in +// duplicate-check results or progress messages. +func redactedError(err error) error { + if err == nil { + return nil + } + return errors.New(redaction.RedactValue(err.Error(), nil)) +} + +func cztJSONGet(ctx context.Context, client *http.Client, endpoint string, params url.Values) (int, any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, nil, fmt.Errorf("CZT create request: %w", err) + } + req.URL.RawQuery = params.Encode() + resp, err := client.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("CZT request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, nil, nil + } + var payload any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return resp.StatusCode, nil, fmt.Errorf("dupechecking: decode JSON GET response: %w", err) + } + return resp.StatusCode, payload, nil +} + +func cztTrackerConfig(cfg config.Config) (config.TrackerConfig, bool) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "CZT") { + return entry, true + } + } + return config.TrackerConfig{}, false +} + +func firstValue(values map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := values[key]; ok { + return value + } + } + return nil +} + +func stringFromAny(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func intFromAny(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/czt/dupe_test.go b/internal/trackers/impl/standalone/czt/dupe_test.go new file mode 100644 index 00000000..add7f7d2 --- /dev/null +++ b/internal/trackers/impl/standalone/czt/dupe_test.go @@ -0,0 +1,501 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package czt + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +func TestCZTHandlerSearchUsesPasskeyAndParsesArray(t *testing.T) { + t.Parallel() + + const title = "Movie & Show/2024" + + tests := []struct { + name string + cfg config.TrackerConfig + meta api.DuplicateSubject + }{ + { + name: "passkey only", + cfg: config.TrackerConfig{Passkey: "passkey123"}, + meta: api.DuplicateSubject{Release: api.ReleaseInfo{Title: title}}, + }, + { + name: "both credentials use passkey", + cfg: config.TrackerConfig{APIKey: "bearer-token", Passkey: "passkey123"}, + meta: api.DuplicateSubject{Release: api.ReleaseInfo{Title: title}}, + }, + { + name: "padded passkey and title fallback", + cfg: config.TrackerConfig{Passkey: " passkey123 "}, + meta: api.DuplicateSubject{ReleaseName: title}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handlerErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := assertCZTSearchRequest(r, title); err != nil { + select { + case handlerErr <- err: + default: + } + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, `[{"id":"77","name":"Movie.2024.1080p.WEB-DL-GRP","url":"https://czteam.me/details.php?id=77","size":12345}]`) + })) + defer server.Close() + + handler := cztHandler{ + cfg: cztTestConfig(tc.cfg), + http: server.Client(), + logger: api.NopLogger{}, + baseURL: server.URL, + } + entries, notes, err := adapterEvidence(handler.Search(context.Background(), tc.meta)) + select { + case err := <-handlerErr: + t.Fatalf("handler: %v", err) + default: + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("unexpected notes: %v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + entry := entries[0] + if entry.ID != "77" || entry.Name != "Movie.2024.1080p.WEB-DL-GRP" || entry.Link == "" { + t.Fatalf("unexpected entry: %#v", entry) + } + if !entry.SizeKnown || entry.SizeBytes != 12345 { + t.Fatalf("unexpected size fields: %#v", entry) + } + }) + } +} + +func TestCZTHandlerSearchPrefersFullReleaseName(t *testing.T) { + t.Parallel() + + const releaseName = "Movie.2024.1080p.WEB-DL-GRP" + + handlerErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := assertCZTSearchRequest(r, releaseName); err != nil { + select { + case handlerErr <- err: + default: + } + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, `[{"id":"77","name":"Movie.2024.1080p.WEB-DL-GRP","url":"https://czteam.me/details.php?id=77"}]`) + })) + defer server.Close() + + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: server.Client(), + logger: api.NopLogger{}, + baseURL: server.URL, + } + entries, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + ReleaseName: releaseName, + Release: api.ReleaseInfo{Title: "Movie"}, + })) + select { + case err := <-handlerErr: + t.Fatalf("handler: %v", err) + default: + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("unexpected notes: %v", notes) + } + if len(entries) != 1 || entries[0].Name != releaseName { + t.Fatalf("expected release-name result, got %#v", entries) + } +} + +func TestCZTHandlerSearchParsesWrappedAndAliasedResults(t *testing.T) { + t.Parallel() + + const title = "Movie.2024.1080p.WEB-DL-GRP" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := assertCZTSearchRequest(r, title); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, `{"data":{"results":[{"torrent_id":77,"torrent_name":"Movie.2024.1080p.WEB-DL-GRP","details_url":"https://czteam.me/details.php?id=77","size_bytes":12345}]}}`) + })) + defer server.Close() + + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: server.Client(), + logger: api.NopLogger{}, + baseURL: server.URL, + } + entries, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + ReleaseName: title, + })) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("unexpected notes: %v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %#v", entries) + } + entry := entries[0] + if entry.ID != "77" || entry.Name != title || entry.Link != "https://czteam.me/details.php?id=77" { + t.Fatalf("unexpected entry: %#v", entry) + } + if !entry.SizeKnown || entry.SizeBytes != 12345 { + t.Fatalf("unexpected size fields: %#v", entry) + } +} + +func TestCZTHandlerSearchCancellationAfterResponseReturnsNoEntries(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + client := &http.Client{Transport: cztRoundTripFunc(func(req *http.Request) (*http.Response, error) { + cancel() + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[{"id":"77","name":"Movie","url":"https://czteam.me/details.php?id=77"}]`)), + Request: req, + }, nil + })} + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: client, + logger: api.NopLogger{}, + } + + entries, notes, err := adapterEvidence(handler.Search(ctx, api.DuplicateSubject{ + Release: api.ReleaseInfo{Title: "Movie"}, + })) + if err == nil { + t.Fatal("expected context cancellation error") + } + if len(entries) != 0 || len(notes) != 0 { + t.Fatalf("expected no entries or notes after cancellation, got entries=%v notes=%v", entries, notes) + } +} + +func TestCZTHandlerSearchCancellationDuringRequestPropagatesContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + client := &http.Client{Transport: cztRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + cancel() + return nil, context.Canceled + })} + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: client, + logger: api.NopLogger{}, + } + + entries, notes, err := adapterEvidence(handler.Search(ctx, api.DuplicateSubject{ + Release: api.ReleaseInfo{Title: "Movie"}, + })) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", err) + } + if err != nil && strings.Contains(err.Error(), "CZT search failed") { + t.Fatalf("expected cancellation before CZT search wrapping, got %v", err) + } + if len(entries) != 0 || len(notes) != 0 { + t.Fatalf("expected no entries or notes after cancellation, got entries=%v notes=%v", entries, notes) + } +} + +type cztRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f cztRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCZTHandlerSearchRequestAssertionReportsOnTestGoroutine(t *testing.T) { + t.Parallel() + + const title = "Movie & Show/2024" + + tests := []struct { + name string + target func(baseURL string) string + wantErrPart string + }{ + { + name: "wrong path", + target: func(baseURL string) string { + return baseURL + "/wrong" + }, + wantErrPart: "unexpected path", + }, + { + name: "wrong query", + target: func(baseURL string) string { + return baseURL + "/api.php?action=search-torrents&type=name&passkey=passkey123&query=Wrong&incldead=1" + }, + wantErrPart: "query", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + handlerErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := assertCZTSearchRequest(r, title); err != nil { + select { + case handlerErr <- err: + default: + } + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, tc.target(server.URL), nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, resp.StatusCode) + } + + select { + case err := <-handlerErr: + if !strings.Contains(err.Error(), tc.wantErrPart) { + t.Fatalf("expected handler assertion error containing %q, got %v", tc.wantErrPart, err) + } + default: + t.Fatalf("expected handler assertion error") + } + }) + } +} + +func TestCZTHandlerSearchCredentialMatrix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.TrackerConfig + wantSkip string + wantError string + }{ + { + name: "missing all credentials skips", + cfg: config.TrackerConfig{}, + wantSkip: "missing passkey", + }, + { + name: "api key only is ignored", + cfg: config.TrackerConfig{APIKey: "bearer-token"}, + wantSkip: "missing passkey", + }, + { + name: "padded api key only is ignored", + cfg: config.TrackerConfig{APIKey: " bearer-token "}, + wantSkip: "missing passkey", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + handler := cztHandler{ + cfg: cztTestConfig(tc.cfg), + http: http.DefaultClient, + logger: api.NopLogger{}, + } + result := handler.Search(context.Background(), api.DuplicateSubject{ + Release: api.ReleaseInfo{Title: "Movie"}, + }) + notes, err := result.Notes(), result.Cause() + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error containing %q, got %v", tc.wantError, err) + } + if len(notes) != 0 { + t.Fatalf("expected no skip notes on failure, got %v", notes) + } + return + } + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunMissingCredentials || + !strings.Contains(result.SafeMessage(), tc.wantSkip) { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } + }) + } +} + +func TestCZTHandlerSearchRemoteFailuresReturnErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + wantError string + }{ + { + name: "non 2xx", + statusCode: http.StatusUnauthorized, + body: `{"error":"bad auth"}`, + wantError: "HTTP status 401", + }, + { + name: "auth object", + statusCode: http.StatusOK, + body: `{"error":"bad auth"}`, + wantError: "unexpected response shape", + }, + { + name: "null", + statusCode: http.StatusOK, + body: `null`, + wantError: "empty response", + }, + { + name: "scalar", + statusCode: http.StatusOK, + body: `"ok"`, + wantError: "unexpected response shape", + }, + { + name: "malformed", + statusCode: http.StatusOK, + body: `{`, + wantError: "decode JSON GET response", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.statusCode) + _, _ = io.WriteString(w, tc.body) + })) + defer server.Close() + + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: server.Client(), + logger: api.NopLogger{}, + baseURL: server.URL, + } + _, notes, err := adapterEvidence(handler.Search(context.Background(), api.DuplicateSubject{ + Release: api.ReleaseInfo{Title: "Movie"}, + })) + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error containing %q, got %v", tc.wantError, err) + } + if len(notes) != 0 { + t.Fatalf("expected no skip notes on failure, got %v", notes) + } + if strings.Contains(err.Error(), "passkey123") { + t.Fatalf("error leaked passkey: %v", err) + } + }) + } +} + +func TestCZTHandlerSearchMissingTitleSkipsWithoutHTTP(t *testing.T) { + t.Parallel() + + var called atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called.Store(true) + })) + defer server.Close() + + handler := cztHandler{ + cfg: cztTestConfig(config.TrackerConfig{Passkey: "passkey123"}), + http: server.Client(), + logger: api.NopLogger{}, + baseURL: server.URL, + } + result := handler.Search(context.Background(), api.DuplicateSubject{}) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunMissingMetadata || + !strings.Contains(result.SafeMessage(), "missing title") { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } + if called.Load() { + t.Fatalf("expected no HTTP call") + } +} + +func cztTestConfig(tracker config.TrackerConfig) config.Config { + return config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "CZT": tracker, + }}} +} + +func assertCZTSearchRequest(r *http.Request, title string) error { + if r.URL.Path != "/api.php" { + return fmt.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("action"); got != "search-torrents" { + return fmt.Errorf("action query: got %q", got) + } + if got := r.URL.Query().Get("type"); got != "name" { + return fmt.Errorf("type query: got %q", got) + } + if got := r.URL.Query().Get("passkey"); got != "passkey123" { + return errors.New("passkey query mismatch") + } + if got := r.URL.Query().Get("query"); got != title { + return fmt.Errorf("query: got %q", got) + } + if got := r.URL.Query().Get("incldead"); got != "1" { + return fmt.Errorf("incldead query: got %q", got) + } + if got := r.Header.Get("Authorization"); got != "" { + return errors.New("unexpected authorization header") + } + return nil +} diff --git a/internal/trackers/impl/standalone/czt/profile.go b/internal/trackers/impl/standalone/czt/profile.go new file mode 100644 index 00000000..9872b6d1 --- /dev/null +++ b/internal/trackers/impl/standalone/czt/profile.go @@ -0,0 +1,35 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package czt + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns CZT identity, preparation, dupe, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: trackerName, + BaseURL: defaultBaseURL, + DescriptionGroup: descGroup, + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDBIDOnly}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "CzT"}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"czteam.me"}}, + } +} + +// New returns a fresh CZT definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/czt/upload.go b/internal/trackers/impl/standalone/czt/upload.go new file mode 100644 index 00000000..0caacc1c --- /dev/null +++ b/internal/trackers/impl/standalone/czt/upload.go @@ -0,0 +1,999 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +// Package czt implements uploads to CZTeam (CZT) via its dedicated JSON +// endpoint takeupload_api.php. +// +// Unlike most impls in this repo CZTeam is not a UNIT3D site and does not need a +// cookie jar: the user's passkey authenticates the multipart POST. The endpoint +// returns the registered .torrent inline as base64, already personalized with +// the uploader's announce passkey and source=CzT. +package czt + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/anacrolix/torrent/metainfo" + + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + trackerName = "CZT" + descGroup = "czt" + defaultBaseURL = "https://czteam.me" + uploadPath = "/takeupload_api.php" + uploadTimeout = 120 * time.Second + defaultCZTPostCancelGrace = 5 * time.Second + cztUploadDecodeFieldBytes = 512 +) + +var cztPostCancelGrace atomic.Int64 + +func init() { + cztPostCancelGrace.Store(int64(defaultCZTPostCancelGrace)) +} + +var ( + newCZTUploadHTTPClient = func() *http.Client { + return &http.Client{Timeout: uploadTimeout} + } + cztBaseURL = defaultBaseURL +) + +// uploadResponse mirrors the JSON returned by takeupload_api.php. A 201 carries +// the full set; a 409 duplicate still returns id/name/download_url/torrent_b64. +type uploadResponse struct { + ID int `json:"id"` + Name string `json:"name"` + InfoHash string `json:"infohash"` + DownloadURL string `json:"download_url"` + TorrentB64 string `json:"torrent_b64"` + Error string `json:"error"` +} + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + files []commonhttp.FileField + endpoint string + baseURL string + questionnaire *api.TrackerQuestionnaire +} + +func upload(ctx context.Context, req trackers.PreparationInput) (api.UploadSummary, error) { + if err := ctx.Err(); err != nil { + return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) + } + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, prepareUpload) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: CZT submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req, req.Intent == trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, state.files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, +) (api.UploadSummary, error) { + if err := ctx.Err(); err != nil { + return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) + } + + httpReq, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodPost, state.endpoint, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: CZT build upload request: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + // Once the CZT upload POST is sent, cancellation can race with an + // irreversible remote 201. Give the tracker a short grace to return that + // result, but do not wait for the full client timeout after caller cancel. + postCtx, startPost, cancelPost := newCZTPostRequestContext(ctx) + defer cancelPost() + client := cztUploadHTTPClientWithStartHook(newCZTUploadHTTPClient(), startPost) + execution, err := commonhttp.ExecuteUpload(client, httpReq.WithContext(postCtx), commonhttp.UploadExecutionOptions{ + Tracker: trackerName, + SuccessStatus: func(status int) bool { return status == http.StatusCreated }, + SuccessBody: commonhttp.FullSuccessBody, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: CZT execute upload: %w", err) + } + responseBody, responsePreview := execution.Body, execution.Preview + if execution.StatusCode != http.StatusCreated { + if err := ctx.Err(); err != nil { + return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) + } + } + + // Only a fresh 201 with a torrent id is a successful upload. A 409 means the + // release name already exists; surface it as an error (the response still + // carries the existing torrent for callers who want to cross-seed). + if execution.StatusCode != http.StatusCreated { + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, trackerName, "upload_failure", responsePreview, ".json") + return api.UploadSummary{}, commonhttp.UploadHTTPError(trackerName, execution.StatusCode, responsePreview) + } + + torrentIDValue, err := parseCZTUploadID(responseBody) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: CZT parse upload response id: %w", err) + } + if torrentIDValue <= 0 { + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, trackerName, "upload_failure", responsePreview, ".json") + return api.UploadSummary{}, commonhttp.UploadHTTPError(trackerName, execution.StatusCode, responsePreview) + } + + torrentID := strconv.Itoa(torrentIDValue) + torrentURL := state.baseURL + "/details.php?id=" + torrentID + summary := api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: trackerName, + TorrentID: torrentID, + TorrentURL: torrentURL, + }}} + + parsed, err := parseCZTUploadResponse(responseBody) + if err != nil { + warnCZTLocalUploadFailure(req.Logger, "parse upload response", err) + } + + if strings.TrimSpace(parsed.DownloadURL) != "" { + downloadURL, err := joinCZTURL(state.baseURL, parsed.DownloadURL) + if err != nil { + warnCZTLocalUploadFailure(req.Logger, "upload response download_url", err) + } else { + summary.UploadedTorrents[0].DownloadURL = downloadURL + } + } + + if err := ctx.Err(); err != nil { + warnCZTLocalUploadFailure(req.Logger, "post-success cancellation", err) + return finalizeCZTUploadSummary(summary), nil + } + + // The endpoint returns the registered .torrent inline (base64), already + // personalized with the uploader's announce passkey and source=CzT, so we + // persist that directly rather than re-deriving an announce URL. + artifactPath, err := persistReturnedTorrent(req, parsed.TorrentB64) + if err != nil { + warnCZTLocalUploadFailure(req.Logger, "persist returned torrent", err) + return finalizeCZTUploadSummary(summary), nil + } + if strings.TrimSpace(artifactPath) != "" { + summary.UploadedTorrents[0].TorrentPath = artifactPath + } + + if err := ctx.Err(); err != nil { + warnCZTLocalUploadFailure(req.Logger, "post-success cancellation", err) + return finalizeCZTUploadSummary(summary), nil + } + + return finalizeCZTUploadSummary(summary), nil +} + +func warnCZTLocalUploadFailure(logger api.Logger, step string, err error) { + if logger == nil || err == nil { + return + } + logger.Warnf("trackers: CZT upload completed remotely but local %s failed: %v", step, err) +} + +// newCZTPostRequestContext lets an in-flight POST outlive caller cancellation +// for a short grace period so an irreversible 201 response can still be +// accounted for, then cancels the request before the full client timeout. +func newCZTPostRequestContext(ctx context.Context) (context.Context, func() error, context.CancelFunc) { + postCtx, cancelPost := context.WithCancel(context.WithoutCancel(ctx)) + startPost := func() error { return nil } + if ctx.Done() == nil { + return postCtx, startPost, cancelPost + } + + started := make(chan struct{}) + done := make(chan struct{}) + var startedFlag atomic.Bool + var startOnce sync.Once + var cancelOnce sync.Once + startPost = func() error { + if startedFlag.Load() { + return nil + } + if err := ctx.Err(); err != nil { + cancelPost() + return fmt.Errorf("context canceled: %w", err) + } + startOnce.Do(func() { + startedFlag.Store(true) + close(started) + }) + return nil + } + + waitGrace := func() { + timer := time.NewTimer(time.Duration(cztPostCancelGrace.Load())) + defer timer.Stop() + select { + case <-timer.C: + cancelPost() + case <-done: + } + } + + go func() { + select { + case <-started: + select { + case <-ctx.Done(): + waitGrace() + case <-done: + } + case <-ctx.Done(): + select { + case <-started: + waitGrace() + default: + cancelPost() + } + case <-done: + } + }() + + return postCtx, startPost, func() { + cancelOnce.Do(func() { + close(done) + cancelPost() + }) + } +} + +type cztPostStartTransport struct { + base http.RoundTripper + start func() error +} + +// RoundTrip records the exact start of the irreversible CZT POST and rejects +// the request if the caller canceled before the transport begins sending it. +func (t cztPostStartTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.start(); err != nil { + return nil, fmt.Errorf("start CZT upload request: %w", err) + } + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("CZT upload round trip: %w", err) + } + return resp, nil +} + +func cztUploadHTTPClientWithStartHook(client *http.Client, start func() error) *http.Client { + if client == nil { + client = &http.Client{Timeout: uploadTimeout} + } + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + out := *client + out.Transport = cztPostStartTransport{base: base, start: start} + return &out +} + +// finalizeCZTUploadSummary drops uploaded rows that cannot be injected or +// downloaded locally while preserving the accepted remote upload count. +func finalizeCZTUploadSummary(summary api.UploadSummary) api.UploadSummary { + if len(summary.UploadedTorrents) == 0 { + return summary + } + out := summary.UploadedTorrents[:0] + for _, uploaded := range summary.UploadedTorrents { + if strings.TrimSpace(uploaded.TorrentPath) != "" || strings.TrimSpace(uploaded.DownloadURL) != "" { + out = append(out, uploaded) + } + } + summary.UploadedTorrents = out + return summary +} + +func parseCZTUploadID(body []byte) (int, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return 0, redactCZTUploadDecodeError("decode CZT upload response fields", err) + } + var id int + if err := json.Unmarshal(fields["id"], &id); err != nil { + return 0, redactCZTUploadDecodeError("decode CZT upload response id", err) + } + return id, nil +} + +func parseCZTUploadResponse(body []byte) (uploadResponse, error) { + var parsed uploadResponse + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return parsed, redactCZTUploadDecodeError("decode CZT upload response fields", err) + } + if err := json.Unmarshal(fields["id"], &parsed.ID); err != nil { + return parsed, redactCZTUploadDecodeError("decode CZT upload response id", err) + } + var fieldErrs []error + parseOptionalStringField(fields, "name", &parsed.Name, &fieldErrs) + parseOptionalStringField(fields, "infohash", &parsed.InfoHash, &fieldErrs) + parseOptionalStringField(fields, "download_url", &parsed.DownloadURL, &fieldErrs) + parseOptionalStringField(fields, "torrent_b64", &parsed.TorrentB64, &fieldErrs) + parseOptionalStringField(fields, "error", &parsed.Error, &fieldErrs) + return parsed, errors.Join(fieldErrs...) +} + +// redactCZTUploadDecodeError preserves parse context while removing any +// secret-bearing fragments that encoding/json may echo from the response body. +func redactCZTUploadDecodeError(context string, err error) error { + return fmt.Errorf("%s: %s", context, redaction.RedactValue(err.Error(), nil)) +} + +func parseOptionalStringField(fields map[string]json.RawMessage, name string, dest *string, errs *[]error) { + raw := fields[name] + if len(raw) == 0 { + return + } + if err := json.Unmarshal(raw, dest); err != nil { + preview := string(raw) + if len(preview) > cztUploadDecodeFieldBytes { + preview = preview[:cztUploadDecodeFieldBytes] + "..." + } + *errs = append(*errs, fmt.Errorf("%s: %s", name, redaction.RedactValue(fmt.Sprintf("value=%s: %s", preview, err.Error()), nil))) + } +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + fields := maps.Clone(state.fields) + if _, ok := fields["passkey"]; ok { + fields["passkey"] = "[redacted]" + } + blockedReason := "" + if missingRequiredCategory(state) { + blockedReason = "answer the category questionnaire to continue" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: trackerName, + BlockedReason: blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: descGroup, + Description: state.description, + Endpoint: state.endpoint, + Payload: fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, requireCategory bool) (uploadState, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + + assets := uploadDescriptionAssets(ctx, req) + + // CZTeam stores two description fields separately: `descr` holds the raw + // MediaInfo/BDInfo dump, and `user_descr` holds the free-form BBCode body + // (user notes + screenshot images). + mediaInfo := buildMediaInfo(req) + userDescr := buildDescription(req, assets) + releaseName := resolveName(req.Meta) + baseURL := resolveBaseURL() + passkey := strings.TrimSpace(req.TrackerConfig.Passkey) + if passkey == "" { + return uploadState{}, errors.New("trackers: CZT missing passkey") + } + + category, err := resolveCategory(req.Meta) + if err != nil { + if requireCategory { + return uploadState{}, err + } + category = "" + } + + fields := map[string]string{ + "name": releaseName, + } + if category != "" { + fields["category"] = category + } + if strings.TrimSpace(mediaInfo) != "" { + fields["descr"] = mediaInfo + } + if strings.TrimSpace(userDescr) != "" { + fields["user_descr"] = userDescr + } + if imdb := imdbID(req.Meta); imdb != "" { + fields["imdb_id"] = imdb + } + // resolution/codec/container/source are validated server-side against the + // tracker's allowed value set; unknown values are dropped, not rejected. + if res := strings.TrimSpace(req.Meta.Release.Resolution); res != "" { + fields["resolution"] = res + } + if codec := firstCodec(req.Meta); codec != "" { + fields["codec"] = codec + } + if container := strings.TrimSpace(req.Meta.Container); container != "" { + fields["container"] = container + } + if source := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(req.Meta.Source, req.Meta.Release.Source)); source != "" { + fields["source"] = source + } + fields["passkey"] = passkey + + return uploadState{ + torrentPath: torrentPath, + description: userDescr, + releaseName: releaseName, + fields: fields, + files: []commonhttp.FileField{{ + FieldName: "file", + FileName: releaseName + ".torrent", + Path: torrentPath, + }}, + endpoint: baseURL + uploadPath, + baseURL: baseURL, + questionnaire: categoryQuestionnaire(req.Meta), + }, nil +} + +// uploadDescriptionAssets uses caller-prepared assets when available, falling +// back to local resolution and an empty asset set on resolution failure. +func uploadDescriptionAssets(_ context.Context, req trackers.PreparationInput) trackers.DescriptionAssets { + if req.Assets != nil { + return *req.Assets + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + return trackers.DescriptionAssets{} + } + return assets +} + +// persistReturnedTorrent decodes the tracker-returned base64 torrent, verifies +// it parses as metainfo, and writes the registered torrent artifact with user +// read/write permissions only. Cleanup errors after replacement return the +// artifact path with a non-nil error so callers can avoid reporting it as +// successfully persisted. +func persistReturnedTorrent(req trackers.PreparationInput, b64 string) (string, error) { + if strings.TrimSpace(b64) == "" { + return "", errors.New("empty torrent_b64") + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err != nil { + return "", fmt.Errorf("decode torrent_b64: %w", err) + } + if len(decoded) == 0 { + return "", errors.New("decoded torrent_b64 is empty") + } + torrentMeta, err := metainfo.Load(bytes.NewReader(decoded)) + if err != nil { + return "", fmt.Errorf("load returned torrent: %w", err) + } + if _, err := torrentMeta.UnmarshalInfo(); err != nil { + return "", fmt.Errorf("unmarshal returned torrent info: %w", err) + } + path, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, trackerName) + if err != nil { + return "", fmt.Errorf("resolve returned torrent path: %w", err) + } + if err := writeReturnedTorrent(path, decoded); err != nil { + var cleanupErr returnedTorrentCleanupError + if errors.As(err, &cleanupErr) { + return path, fmt.Errorf("write returned torrent: %w", err) + } + return "", fmt.Errorf("write returned torrent: %w", err) + } + return path, nil +} + +// writeReturnedTorrent stages returned torrent bytes in the destination +// directory before replacing the final artifact path, so a failed write does +// not truncate an existing registered torrent. +func writeReturnedTorrent(path string, data []byte) error { + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp returned torrent: %w", err) + } + tmpPath := tmpFile.Name() + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(tmpPath) + } + }() + + if err := tmpFile.Chmod(0o600); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("chmod temp returned torrent: %w", err) + } + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("write temp returned torrent: %w", err) + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("sync temp returned torrent: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close temp returned torrent: %w", err) + } + if err := replaceStagedReturnedTorrent(tmpPath, path); err != nil { + return err + } + removeTemp = false + return nil +} + +// replaceStagedReturnedTorrent moves a staged torrent into place. Existing +// artifacts are first moved to a temporary backup and restored if the final +// rename fails. +func replaceStagedReturnedTorrent(tmpPath string, outputPath string) error { + info, err := os.Stat(outputPath) + if err != nil { + if os.IsNotExist(err) { + if renameErr := os.Rename(tmpPath, outputPath); renameErr != nil { + return fmt.Errorf("rename staged returned torrent into place: %w", renameErr) + } + return nil + } + return fmt.Errorf("stat returned torrent: %w", err) + } + if info.IsDir() { + return fmt.Errorf("%s is a directory", outputPath) + } + + backupPath, err := reserveReturnedTorrentBackupPath(filepath.Dir(outputPath), filepath.Base(outputPath)+".backup-*") + if err != nil { + return err + } + if err := os.Rename(outputPath, backupPath); err != nil { + _ = os.Remove(backupPath) + return fmt.Errorf("backup existing returned torrent: %w", err) + } + if err := os.Rename(tmpPath, outputPath); err != nil { + restoreErr := os.Rename(backupPath, outputPath) + if restoreErr != nil { + return errors.Join(err, fmt.Errorf("restore existing returned torrent: %w", restoreErr)) + } + return fmt.Errorf("replace existing returned torrent: %w", err) + } + if err := removeReturnedTorrentBackup(backupPath); err != nil { + return returnedTorrentCleanupError{cause: fmt.Errorf("remove returned torrent backup: %w", err)} + } + return nil +} + +type returnedTorrentCleanupError struct { + cause error +} + +func (e returnedTorrentCleanupError) Error() string { + return e.cause.Error() +} + +func (e returnedTorrentCleanupError) Unwrap() error { + return e.cause +} + +var removeReturnedTorrentBackup = os.Remove + +// reserveReturnedTorrentBackupPath reserves and releases a same-directory path +// suitable for temporarily holding the existing registered torrent. +func reserveReturnedTorrentBackupPath(dir string, pattern string) (string, error) { + file, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", fmt.Errorf("create temp returned torrent backup marker: %w", err) + } + path := file.Name() + closeErr := file.Close() + removeErr := os.Remove(path) + if closeErr != nil || removeErr != nil { + return "", errors.Join(closeErr, removeErr) + } + return path, nil +} + +// buildMediaInfo returns the raw MediaInfo/BDInfo text for the CZTeam `descr` +// field. +func buildMediaInfo(req trackers.PreparationInput) string { + return strings.TrimSpace(trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, req.Meta)) +} + +// buildDescription assembles the CZTeam `user_descr` body: the (possibly +// user-edited) description text followed by a BBCode screenshot block. Kept as a +// separate function so definition.BuildDescription can drive the description +// builder UI with the same output. +func buildDescription(_ trackers.PreparationInput, assets trackers.DescriptionAssets) string { + // A "final" description is the already-assembled body (saved override or + // canonical group description) with screenshots embedded; the resolver does + // not clear assets.Screenshots here, so re-appending would duplicate them. + // Use it verbatim, matching the assets.Final convention other impls follow. + if assets.Final { + return strings.TrimSpace(assets.Description) + } + parts := make([]string, 0, 2) + if body := strings.TrimSpace(assets.Description); body != "" { + parts = append(parts, body) + } + if shots := bbcodeScreenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + return strings.TrimSpace(strings.Join(parts, "\n\n")) +} + +// bbcodeScreenshotBlock renders at most two raw screenshot image URLs. CZTeam's +// formatter accepts plain [img] tags here; linked/thumbnail URLs are ignored. +func bbcodeScreenshotBlock(images []api.ScreenshotImage) string { + parts := make([]string, 0, 2) + for _, image := range images { + raw := strings.TrimSpace(image.RawURL) + if raw == "" { + continue + } + parts = append(parts, "[img]"+raw+"[/img]") + if len(parts) == 2 { + break + } + } + return strings.Join(parts, "\n") +} + +// resolveBaseURL returns the fixed CZTeam origin used for upload, details, and +// returned download URLs. CZTeam is not a tracker family/configurable endpoint. +func resolveBaseURL() string { + if trimmed := strings.TrimRight(strings.TrimSpace(cztBaseURL), "/"); trimmed != "" { + return trimmed + } + return defaultBaseURL +} + +// joinCZTURL resolves a tracker-provided download URL against the CZTeam base +// URL, strips URL userinfo, and rejects empty, non-addressable, or +// cross-host results. +func joinCZTURL(baseURL string, rawRef string) (string, error) { + trimmedRef := strings.TrimSpace(rawRef) + if trimmedRef == "" { + return "", errors.New("empty URL") + } + base, err := url.Parse(resolveCZTURLBase(baseURL) + "/") + if err != nil { + return "", fmt.Errorf("parse base URL: %w", err) + } + if base.Scheme == "" || base.Host == "" { + return "", errors.New("base URL must be absolute") + } + ref, err := url.Parse(trimmedRef) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + resolved := base.ResolveReference(ref) + if resolved.Scheme == "" || resolved.Host == "" { + return "", errors.New("resolved URL must be absolute") + } + if !strings.EqualFold(resolved.Scheme, base.Scheme) || !strings.EqualFold(resolved.Host, base.Host) { + return "", errors.New("resolved URL must stay on configured CZT host") + } + if !hasUsableCZTDownloadPath(resolved.Path) { + return "", errors.New("resolved URL has no torrent path") + } + resolved.User = nil + return resolved.String(), nil +} + +func hasUsableCZTDownloadPath(path string) bool { + trimmed := strings.TrimSpace(path) + return trimmed != "" && trimmed != "/" +} + +// resolveCZTURLBase normalizes a base URL before resolving tracker-provided +// relative download URLs against it. +func resolveCZTURLBase(baseURL string) string { + trimmed := strings.TrimSpace(baseURL) + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return strings.TrimRight(trimmed, "/") + } + parsed.Path = "" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + parsed.User = nil + return strings.TrimRight(parsed.String(), "/") +} + +func resolveName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.SceneName); name != "" { + return name + } + return strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename)) +} + +// cztCategory pairs a CZTeam categories.id with its display name. +type cztCategory struct { + id string + name string +} + +// cztCategories lists CZTeam upload categories for the upload-time override +// dropdown. upbrr auto-detects only video categories from metadata; everything +// else (software, games, music, XXX, images, docs, …) is chosen here. +var cztCategories = []cztCategory{ + {"1", "XxX"}, + {"4", "Games/PC ISO"}, + {"5", "TvEps/HD"}, + {"6", "Music/Audio"}, + {"7", "TvEps"}, + {"9", "Mobile"}, + {"12", "Games/Consoles"}, + {"19", "Movies/XviD"}, + {"20", "Movies/DVD-R"}, + {"21", "Games/PC Rips"}, + {"22", "Software"}, + {"23", "Anime"}, + {"24", "Images"}, + {"25", "Docs"}, + {"28", "Movies/DVD-RO"}, + {"29", "Movies/HD"}, + {"30", "Music/MVID"}, + {"31", "MAC"}, + {"32", "Sports"}, + {"33", "Movies/HDTV-RO"}, + {"34", "TvEps/HD-RO"}, + {"35", "Music/Lossless"}, + {"36", "Full BluRay-RO"}, + {"37", "Movies/3D"}, + {"38", "Movies-RO"}, +} + +func categoryNames() []string { + out := make([]string, 0, len(cztCategories)) + for _, c := range cztCategories { + out = append(out, c.name) + } + return out +} + +func categoryIDForName(name string) string { + name = strings.TrimSpace(name) + for _, c := range cztCategories { + if strings.EqualFold(c.name, name) { + return c.id + } + } + return "" +} + +func categoryNameForID(id string) string { + for _, c := range cztCategories { + if c.id == id { + return c.name + } + } + return "" +} + +// categoryQuestionnaire offers a (non-blocking) category dropdown pre-filled +// with the auto-detected category, so the user can override it for content +// upbrr can't classify from video metadata. +func categoryQuestionnaire(meta api.UploadSubject) *api.TrackerQuestionnaire { + auto := autoCategory(meta) + return &api.TrackerQuestionnaire{ + Tracker: trackerName, + Fields: []api.TrackerQuestionnaireField{{ + Key: "category", + Label: "Category", + Kind: "select", + Options: categoryNames(), + Value: categoryNameForID(auto), + Help: "Auto-detected from video metadata. Override for software, games, music, XXX, etc.", + Required: auto == "", + }}, + } +} + +// resolveCategory returns the CZTeam category id: an explicit questionnaire +// override when the user picked one, otherwise the auto-detected video category. +func resolveCategory(meta api.UploadSubject) (string, error) { + if id := resolveQuestionnaireCategory(standalone.QuestionnaireAnswers(meta, trackerName)["category"]); id != "" { + return id, nil + } + if id := autoCategory(meta); id != "" { + return id, nil + } + return "", errors.New("trackers: CZT category requires explicit questionnaire selection for non-video content") +} + +func resolveQuestionnaireCategory(value string) string { + if id := categoryIDForName(value); id != "" { + return id + } + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + for _, c := range cztCategories { + if c.id == trimmed { + return c.id + } + } + return "" +} + +// autoCategory maps prepared metadata to a CZTeam numeric categories.id when +// metadata supports automatic classification. Unknown non-video content returns +// empty so callers can require an explicit questionnaire category instead of +// falling back to a movie bucket. +func autoCategory(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + + ro := hasRomanianSubs(meta) + hd := isHD(meta.Release.Resolution) + + switch { + case meta.Anime: + return "23" // Anime + case category == api.CanonicalCategoryTV: + if hd && ro { + return "34" // TvEps/HD-RO + } + if hd { + return "5" // TvEps/HD + } + return "7" // TvEps (no SD-RO TV category exists) + } + + // Movies. + src := strings.ToUpper(metautil.FirstNonEmptyTrimmed(meta.Source, meta.Release.Source)) + isDVD := strings.Contains(src, "DVD") || strings.EqualFold(meta.DiscType, "DVD") || strings.EqualFold(meta.Type, "DVDRIP") + isFullBluRay := strings.EqualFold(meta.DiscType, "BDMV") || + (strings.EqualFold(meta.Type, "REMUX") && strings.Contains(src, "BLURAY")) + if category != api.CanonicalCategoryMovie { + return "" + } + + if ro { + switch { + case isFullBluRay: + return "36" // Full BluRay-RO + case isDVD: + return "28" // Movies/DVD-RO + case hd: + return "33" // Movies/HDTV-RO + default: + return "38" // Movies-RO + } + } + switch { + case isDVD: + return "20" // Movies/DVD-R + case hd: + return "29" // Movies/HD + case hasCodec(meta, "XviD"): + return "19" // Movies/XviD + default: + return "29" // default to Movies/HD when movie evidence exists + } +} + +func missingRequiredCategory(state uploadState) bool { + if state.questionnaire == nil { + return false + } + if strings.TrimSpace(state.fields["category"]) != "" { + return false + } + for _, field := range state.questionnaire.Fields { + if field.Key == "category" && field.Required { + return true + } + } + return false +} + +func hasRomanianSubs(meta api.UploadSubject) bool { + for _, s := range meta.SubtitleLanguages { + v := strings.ToLower(strings.TrimSpace(s)) + if v == "ro" || v == "rum" || v == "ron" || strings.HasPrefix(v, "roman") { + return true + } + } + return false +} + +func isHD(res string) bool { + res = strings.TrimSpace(res) + for _, prefix := range []string{"720", "1080", "2160", "4320"} { + if strings.HasPrefix(res, prefix) { + return true + } + } + return false +} + +func hasCodec(meta api.UploadSubject, want string) bool { + for _, c := range meta.Release.Codec { + if strings.EqualFold(strings.TrimSpace(c), want) { + return true + } + } + return false +} + +func firstCodec(meta api.UploadSubject) string { + for _, c := range meta.Release.Codec { + if v := strings.TrimSpace(c); v != "" { + return v + } + } + return "" +} + +func imdbID(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) +} diff --git a/internal/trackers/impl/standalone/czt/upload_test.go b/internal/trackers/impl/standalone/czt/upload_test.go new file mode 100644 index 00000000..956c7d1d --- /dev/null +++ b/internal/trackers/impl/standalone/czt/upload_test.go @@ -0,0 +1,1271 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package czt + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + mkbrr "github.com/autobrr/mkbrr/torrent" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestUploadSuccessPersistsReturnedTorrentAndUsesProvidedAssets(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + server.assertNoHandlerError(t) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected one uploaded torrent, got %+v", result) + } + uploaded := result.UploadedTorrents[0] + if uploaded.TorrentURL != server.URL+"/details.php?id=123" { + t.Fatalf("unexpected torrent URL: %q", uploaded.TorrentURL) + } + if uploaded.DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("unexpected download URL: %q", uploaded.DownloadURL) + } + if strings.TrimSpace(uploaded.TorrentPath) == "" { + t.Fatal("expected persisted returned torrent path") + } + payload, err := os.ReadFile(uploaded.TorrentPath) + if err != nil { + t.Fatalf("read persisted torrent: %v", err) + } + if string(payload) != string(returnedTorrent) { + t.Fatalf("persisted torrent did not match returned bytes") + } +} + +func TestUploadUsesProfileEndpointAndIgnoresAPIKey(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) + defer server.Close() + + req := cztUploadRequest(t, server.URL) + req.TrackerConfig.APIKey = "stale-api-key" + + result, err := upload(context.Background(), req) + server.assertNoHandlerError(t) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected upload success, got %+v", result) + } +} + +func TestUploadRequiresPasskey(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + req := cztUploadRequest(t, server.URL) + req.TrackerConfig.Passkey = "" + + result, err := upload(context.Background(), req) + if err == nil { + t.Fatal("expected missing passkey error") + } + if !strings.Contains(err.Error(), "missing passkey") { + t.Fatalf("expected missing passkey error, got %v", err) + } + if requests.Load() != 0 { + t.Fatalf("expected no remote request without passkey, got %d", requests.Load()) + } + if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result, got %+v", result) + } +} + +func TestUploadTestServerReportsHandlerAssertionOnTestGoroutine(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + server := newCZTUploadTestServer(t, returnedTorrent, "/wrong-upload-path") + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + server.assertHandlerErrorContains(t, `expected upload path "/wrong-upload-path"`) + if err == nil { + t.Fatal("expected upload error") + } + if !strings.Contains(err.Error(), "status=400") { + t.Fatalf("expected HTTP 400 upload error, got %v", err) + } + if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result on handler assertion failure, got %+v", result) + } +} + +func TestUploadReportsRemoteSuccessWhenReturnedTorrentB64IsInvalid(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":"not-base64"}`)) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success without artifact failure, got %+v", result) + } + if result.UploadedTorrents[0].TorrentPath != "" { + t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) + } +} + +func TestUploadReportsRemoteSuccessWhenReturnedTorrentB64IsEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":" "}`)) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success without artifact failure, got %+v", result) + } + if result.UploadedTorrents[0].TorrentPath != "" { + t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) + } +} + +func TestUploadReportsRemoteSuccessWhenReturnedTorrentIsCorrupt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"id":123,"name":"Release","download_url":"/download.php?id=123","torrent_b64":%q}`, + base64.StdEncoding.EncodeToString([]byte("registered-torrent")), + ) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success without artifact failure, got %+v", result) + } + if result.UploadedTorrents[0].TorrentPath != "" { + t.Fatalf("expected no persisted torrent path, got %q", result.UploadedTorrents[0].TorrentPath) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected download URL fallback, got %q", result.UploadedTorrents[0].DownloadURL) + } +} + +func TestUploadReportsRemoteSuccessWhenDownloadURLIsOffsite(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"id":123,"name":"Release","download_url":"https://evil.example/download.php?id=123","torrent_b64":%q}`, + base64.StdEncoding.EncodeToString(returnedTorrent), + ) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success with artifact, got %+v", result) + } + if result.UploadedTorrents[0].DownloadURL != "" { + t.Fatalf("expected rejected download URL to be omitted, got %q", result.UploadedTorrents[0].DownloadURL) + } + if strings.TrimSpace(result.UploadedTorrents[0].TorrentPath) == "" { + t.Fatal("expected persisted returned torrent path") + } +} + +func TestUploadKeepsRemoteSuccessWhenNoInjectableArtifactExists(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":123,"name":"Release","download_url":"https://evil.example/download.php?id=123","torrent_b64":"not-base64"}`)) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected remote success count without injectable artifacts, got %+v", result) + } + if len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no injectable artifact entry, got %+v", result.UploadedTorrents) + } +} + +func TestUploadResponseParseBoundaries(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + want string + }{ + { + name: "malformed 201", + statusCode: http.StatusCreated, + body: `{`, + want: "parse upload response", + }, + { + name: "malformed non-201 stays http failure", + statusCode: http.StatusInternalServerError, + body: `{`, + want: "status=500", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + _, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err == nil { + t.Fatal("expected upload error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error containing %q, got %v", tt.want, err) + } + }) + } +} + +func TestCZTUploadDecodeErrorsAreRedacted(t *testing.T) { + err := redactCZTUploadDecodeError("decode CZT upload response fields", errors.New("api_key=secret-passkey Cookie=session-token")) + if err == nil { + t.Fatal("expected redacted error") + } + got := err.Error() + if strings.Contains(got, "secret-passkey") || strings.Contains(got, "session-token") { + t.Fatalf("expected redacted decode error") + } + if !strings.Contains(got, "decode CZT upload response fields") || !strings.Contains(got, "[REDACTED]") { + t.Fatalf("expected context and redaction marker, got %q", got) + } + + if _, err := parseCZTUploadID([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response fields") { + t.Fatalf("expected redacted fields decode path, got %v", err) + } + if _, err := parseCZTUploadID([]byte(`{"id":"bad"}`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response id") { + t.Fatalf("expected redacted id decode path, got %v", err) + } + if _, err := parseCZTUploadResponse([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response fields") { + t.Fatalf("expected redacted response fields decode path, got %v", err) + } + if _, err := parseCZTUploadResponse([]byte(`{"id":"bad"}`)); err == nil || !strings.Contains(err.Error(), "decode CZT upload response id") { + t.Fatalf("expected redacted response id decode path, got %v", err) + } + if _, err := parseCZTUploadResponse([]byte(`{"id":123,"error":{"api_key":"secret-passkey"}}`)); err == nil { + t.Fatal("expected redacted optional field decode path") + } else if got := err.Error(); !strings.Contains(got, "error") || !strings.Contains(got, "[REDACTED]") || strings.Contains(got, "secret-passkey") { + t.Fatalf("expected redacted optional field decode error, got %q", got) + } +} + +func TestUploadSuccessParsesLargeResponse(t *testing.T) { + padding := strings.Repeat("a", 70*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"name":%q,"id":123,"download_url":"/download.php?id=123","torrent_b64":" "}`, + padding, + ) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success without artifact failure, got %+v", result) + } + if result.UploadedTorrents[0].TorrentID != "123" { + t.Fatalf("expected torrent ID 123, got %q", result.UploadedTorrents[0].TorrentID) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected download URL from large response, got %q", result.UploadedTorrents[0].DownloadURL) + } +} + +func TestUploadResponseBadLocalFieldPreservesRemoteSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":123,"download_url":"/download.php?id=123","torrent_b64":123}`)) + })) + defer server.Close() + + result, err := upload(context.Background(), cztUploadRequest(t, server.URL)) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success without artifact, got %+v", result) + } + if result.UploadedTorrents[0].TorrentID != "123" || result.UploadedTorrents[0].TorrentPath != "" { + t.Fatalf("unexpected uploaded torrent: %+v", result.UploadedTorrents[0]) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected usable download URL despite bad local field, got %q", result.UploadedTorrents[0].DownloadURL) + } +} + +func TestUploadPreCanceledContextStopsBeforeRemoteRequest(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := upload(ctx, cztUploadRequest(t, server.URL)) + if err == nil { + t.Fatal("expected cancellation error") + } + if !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("expected context cancellation error, got %v", err) + } + if requests.Load() != 0 { + t.Fatalf("expected no remote request after pre-send cancellation, got %d", requests.Load()) + } + if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result after pre-send cancellation, got %+v", result) + } +} + +func TestUploadCancellationAfterRequestBuildStopsBeforeRemoteRequest(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + originalFactory := newCZTUploadHTTPClient + newCZTUploadHTTPClient = func() *http.Client { + cancel() + return server.Client() + } + t.Cleanup(func() { + newCZTUploadHTTPClient = originalFactory + }) + + result, err := upload(ctx, cztUploadRequest(t, server.URL)) + if err == nil { + t.Fatal("expected cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", err) + } + if requests.Load() != 0 { + t.Fatalf("expected no remote request after post-build cancellation, got %d", requests.Load()) + } + if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result after post-build cancellation, got %+v", result) + } +} + +func TestUploadCancellationAfterResponsePreservesRemoteSuccessWithoutPersistingArtifact(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + ctx, cancel := context.WithCancel(context.Background()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"id":123,"name":"Release","download_url":"download.php?id=123","torrent_b64":%q}`, + base64.StdEncoding.EncodeToString(returnedTorrent), + ) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + cancel() + })) + defer server.Close() + + req := cztUploadRequest(t, server.URL) + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, trackerName) + if err != nil { + t.Fatalf("resolve artifact path: %v", err) + } + result, err := upload(ctx, req) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success after cancellation, got %+v", result) + } + if result.UploadedTorrents[0].DownloadURL != server.URL+"/download.php?id=123" { + t.Fatalf("expected download URL preserved, got %q", result.UploadedTorrents[0].DownloadURL) + } + if result.UploadedTorrents[0].TorrentPath != "" { + t.Fatalf("expected no persisted artifact path, got %q", result.UploadedTorrents[0].TorrentPath) + } + if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { + t.Fatalf("expected no persisted artifact, stat err=%v", statErr) + } +} + +func TestUploadCancellationBeforeNonCreatedResponseReturnsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + cancel() + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"failed"}`)) + })) + defer server.Close() + + result, err := upload(ctx, cztUploadRequest(t, server.URL)) + if err == nil { + t.Fatal("expected cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", err) + } + if strings.Contains(err.Error(), "status=500") { + t.Fatalf("expected cancellation before HTTP error, got %v", err) + } + if result.Uploaded != 0 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result after non-201 cancellation, got %+v", result) + } +} + +func TestUploadCancellationDuringPostCancelsBeforeUploadTimeout(t *testing.T) { + oldGrace := cztPostCancelGrace.Swap(int64(25 * time.Millisecond)) + t.Cleanup(func() { + cztPostCancelGrace.Store(oldGrace) + }) + + requestStarted := make(chan struct{}) + releaseHandler := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + close(requestStarted) + select { + case <-r.Context().Done(): + case <-releaseHandler: + } + })) + defer server.Close() + defer close(releaseHandler) + + ctx, cancel := context.WithCancel(context.Background()) + req := cztUploadRequest(t, server.URL) + done := make(chan struct { + summary api.UploadSummary + err error + }, 1) + go func() { + summary, err := upload(ctx, req) + done <- struct { + summary api.UploadSummary + err error + }{summary: summary, err: err} + }() + + select { + case <-requestStarted: + case <-time.After(time.Second): + t.Fatal("expected remote request to start") + } + + started := time.Now() + cancel() + select { + case result := <-done: + if result.err == nil { + t.Fatal("expected cancellation error") + } + if !errors.Is(result.err, context.Canceled) { + t.Fatalf("expected context cancellation error, got %v", result.err) + } + if result.summary.Uploaded != 0 || len(result.summary.UploadedTorrents) != 0 { + t.Fatalf("expected no uploaded result after canceled POST, got %+v", result.summary) + } + case <-time.After(time.Second): + t.Fatal("expected canceled POST to return before upload timeout") + } + if elapsed := time.Since(started); elapsed >= time.Second { + t.Fatalf("expected canceled POST below upload timeout, elapsed %s", elapsed) + } +} + +func TestUploadCancellationAfterPersistPreservesArtifactSummary(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + ctx, cancel := context.WithCancel(context.Background()) + server := newCZTUploadTestServer(t, returnedTorrent, uploadPath) + defer server.Close() + + req := cztUploadRequest(t, server.URL) + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, trackerName) + if err != nil { + t.Fatalf("resolve artifact path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { + t.Fatalf("create artifact dir: %v", err) + } + if err := os.WriteFile(artifactPath, []byte("existing artifact"), 0o600); err != nil { + t.Fatalf("write existing artifact: %v", err) + } + + oldRemove := removeReturnedTorrentBackup + removeReturnedTorrentBackup = func(path string) error { + err := oldRemove(path) + if strings.Contains(filepath.Base(path), ".backup-") { + cancel() + } + return err + } + defer func() { + removeReturnedTorrentBackup = oldRemove + }() + + result, err := upload(ctx, req) + server.assertNoHandlerError(t) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 1 { + t.Fatalf("expected remote success with artifact after cancellation, got %+v", result) + } + if result.UploadedTorrents[0].TorrentPath != artifactPath { + t.Fatalf("expected artifact path %q, got %q", artifactPath, result.UploadedTorrents[0].TorrentPath) + } +} + +func TestReplaceStagedReturnedTorrentRestoresExistingOnFailure(t *testing.T) { + tmp := t.TempDir() + outputPath := filepath.Join(tmp, "registered.torrent") + original := []byte("existing artifact") + if err := os.WriteFile(outputPath, original, 0o600); err != nil { + t.Fatalf("write existing artifact: %v", err) + } + + err := replaceStagedReturnedTorrent(filepath.Join(tmp, "missing.tmp"), outputPath) + if err == nil { + t.Fatal("expected replacement error") + } + got, readErr := os.ReadFile(outputPath) + if readErr != nil { + t.Fatalf("read restored artifact: %v", readErr) + } + if string(got) != string(original) { + t.Fatalf("expected existing artifact preserved, got %q", got) + } +} + +func TestUploadOmitsArtifactPathWhenBackupCleanupFails(t *testing.T) { + returnedTorrent := validTorrentBytes(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"id":123,"name":"Release","torrent_b64":%q}`, + base64.StdEncoding.EncodeToString(returnedTorrent), + ) + })) + defer server.Close() + + req := cztUploadRequest(t, server.URL) + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, trackerName) + if err != nil { + t.Fatalf("resolve artifact path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { + t.Fatalf("create artifact dir: %v", err) + } + if err := os.WriteFile(artifactPath, []byte("existing artifact"), 0o600); err != nil { + t.Fatalf("write existing artifact: %v", err) + } + + oldRemove := removeReturnedTorrentBackup + removeReturnedTorrentBackup = func(path string) error { + if strings.Contains(filepath.Base(path), ".backup-") { + return os.ErrPermission + } + return oldRemove(path) + } + defer func() { + removeReturnedTorrentBackup = oldRemove + }() + + result, err := upload(context.Background(), req) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 || len(result.UploadedTorrents) != 0 { + t.Fatalf("expected cleanup-error artifact omitted while preserving remote success, got %+v", result) + } +} + +func TestPersistReturnedTorrentReturnsPathWhenBackupCleanupFails(t *testing.T) { + req := cztUploadRequest(t, defaultBaseURL) + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, trackerName) + if err != nil { + t.Fatalf("resolve artifact path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(artifactPath), 0o700); err != nil { + t.Fatalf("create artifact dir: %v", err) + } + original := []byte("existing artifact") + if err := os.WriteFile(artifactPath, original, 0o600); err != nil { + t.Fatalf("write existing artifact: %v", err) + } + + oldRemove := removeReturnedTorrentBackup + removeReturnedTorrentBackup = func(path string) error { + if strings.Contains(filepath.Base(path), ".backup-") { + return os.ErrPermission + } + return oldRemove(path) + } + defer func() { + removeReturnedTorrentBackup = oldRemove + }() + + gotPath, err := persistReturnedTorrent(req, base64.StdEncoding.EncodeToString(validTorrentBytes(t))) + if err == nil { + t.Fatal("expected cleanup error") + } + var cleanupErr returnedTorrentCleanupError + if !errors.As(err, &cleanupErr) { + t.Fatalf("expected cleanup error, got %v", err) + } + if gotPath != artifactPath { + t.Fatalf("expected artifact path %q, got %q", artifactPath, gotPath) + } + payload, readErr := os.ReadFile(artifactPath) + if readErr != nil { + t.Fatalf("read artifact: %v", readErr) + } + if string(payload) == string(original) { + t.Fatal("expected replacement artifact to remain after cleanup failure") + } +} + +func TestJoinCZTURL(t *testing.T) { + tests := []struct { + name string + base string + raw string + want string + wantErr bool + }{ + { + name: "leading slash", + base: "https://czteam.me", + raw: "/download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "no leading slash", + base: "https://czteam.me", + raw: "download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "same host absolute", + base: "https://czteam.me", + raw: "https://czteam.me/download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "base userinfo stripped", + base: "https://user:pass@czteam.me", + raw: "download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "same host absolute userinfo stripped", + base: "https://czteam.me", + raw: "https://user:pass@czteam.me/download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "same host scheme relative", + base: "https://czteam.me", + raw: "//czteam.me/download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "base path ignored", + base: "https://czteam.me/nested/path?x=1", + raw: "download.php?id=1", + want: "https://czteam.me/download.php?id=1", + }, + { + name: "absolute offsite", + base: "https://czteam.me", + raw: "https://cdn.example/download/1", + wantErr: true, + }, + { + name: "scheme-relative offsite", + base: "https://czteam.me", + raw: "//cdn.example/download/1", + wantErr: true, + }, + { + name: "same host wrong scheme", + base: "https://czteam.me", + raw: "http://czteam.me/download.php?id=1", + wantErr: true, + }, + { + name: "root path", + base: "https://czteam.me", + raw: "/", + wantErr: true, + }, + { + name: "root query", + base: "https://czteam.me", + raw: "/?id=1", + wantErr: true, + }, + { + name: "query only", + base: "https://czteam.me", + raw: "?id=1", + wantErr: true, + }, + { + name: "pathless", + base: "https://czteam.me", + raw: "https://czteam.me", + wantErr: true, + }, + { + name: "empty", + base: "https://czteam.me", + raw: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := joinCZTURL(tt.base, tt.raw) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestResolveCategoryMatrix(t *testing.T) { + tests := []struct { + name string + meta api.UploadSubject + want string + wantErr bool + }{ + { + name: "display name answer", + meta: api.UploadSubject{TrackerQuestionnaireAnswers: map[string]map[string]string{trackerName: {"category": "Software"}}}, + want: "22", + }, + { + name: "numeric answer", + meta: api.UploadSubject{TrackerQuestionnaireAnswers: map[string]map[string]string{trackerName: {"category": "6"}}}, + want: "6", + }, + { + name: "movie hd", + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: "MOVIE"}, Release: api.ReleaseInfo{Resolution: "1080p"}}, + want: "29", + }, + { + name: "tv hd ro", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + SeasonInt: 1, + SubtitleLanguages: []string{"ro"}, + }, + want: "34", + }, + { + name: "anime", + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, Anime: true}, + want: "23", + }, + { + name: "parsed anime hint is not canonical identity", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Anime-Video"}}, + wantErr: true, + }, + { + name: "video game hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "video-game"}}, + wantErr: true, + }, + { + name: "game video hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "game-video"}}, + wantErr: true, + }, + { + name: "game movie hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "game movie"}}, + wantErr: true, + }, + { + name: "videogame compound", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "videogame"}}, + wantErr: true, + }, + { + name: "gameplay compound", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "gameplay"}}, + wantErr: true, + }, + { + name: "console hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Games/Consoles"}}, + wantErr: true, + }, + { + name: "release source dvd", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, + Release: api.ReleaseInfo{Source: "DVD"}, + }, + want: "20", + }, + { + name: "documentary hint", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, + Release: api.ReleaseInfo{Category: "Documentary"}, + }, + want: "29", + }, + { + name: "movie documentary hint", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, + Release: api.ReleaseInfo{Category: "Movie Documentary"}, + }, + want: "29", + }, + { + name: "docs hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Docs"}}, + wantErr: true, + }, + { + name: "ebook hint", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "eBook"}}, + wantErr: true, + }, + { + name: "software", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Software"}}, + wantErr: true, + }, + { + name: "music", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Music/Audio"}}, + wantErr: true, + }, + { + name: "music video phrase", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Music Video"}}, + wantErr: true, + }, + { + name: "music video separator", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "music-video"}}, + wantErr: true, + }, + { + name: "music video dotted uppercase", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "MUSIC.VIDEO"}}, + wantErr: true, + }, + { + name: "mvid", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "MVID"}}, + wantErr: true, + }, + { + name: "generic video hint", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, + Release: api.ReleaseInfo{Category: "Video", Resolution: "1080p"}, + }, + want: "29", + }, + { + name: "no hints unknown metadata", + meta: api.UploadSubject{}, + wantErr: true, + }, + { + name: "unknown non-video", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: "Other Data"}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveCategory(tt.meta) + if tt.wantErr { + if err == nil { + t.Fatal("expected category error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("expected category %q, got %q", tt.want, got) + } + }) + } +} + +func TestCategoryQuestionnaireRequiresUnknownNonVideo(t *testing.T) { + questionnaire := categoryQuestionnaire(api.UploadSubject{Release: api.ReleaseInfo{Category: "Other Data"}}) + if questionnaire == nil || len(questionnaire.Fields) != 1 { + t.Fatalf("expected one category questionnaire field, got %+v", questionnaire) + } + field := questionnaire.Fields[0] + if !field.Required { + t.Fatal("expected unknown non-video category to require an explicit answer") + } + if field.Value != "" { + t.Fatalf("expected no default category value, got %q", field.Value) + } +} + +func TestBuildUploadDryRunBlocksMissingRequiredCategory(t *testing.T) { + req := cztUploadRequest(t, defaultBaseURL) + req.Meta.Identity.Category = api.CanonicalCategoryUnknown + req.Meta.Release.Category = "Other Data" + + req.Intent = trackers.PreparationIntentDryRun + plan, failure := New().Prepare(context.Background(), req) + if failure != nil { + t.Fatalf("unexpected dry-run error: %v", failure) + } + entry := plan.DryRun() + if entry.Status != "blocked" { + t.Fatalf("expected blocked dry-run status, got %q", entry.Status) + } + if !strings.Contains(entry.Message, "category questionnaire") { + t.Fatalf("expected category questionnaire message, got %q", entry.Message) + } + if _, ok := entry.Payload["category"]; ok { + t.Fatalf("expected unresolved category omitted from payload, got %q", entry.Payload["category"]) + } + if entry.Questionnaire == nil || len(entry.Questionnaire.Fields) != 1 || !entry.Questionnaire.Fields[0].Required { + t.Fatalf("expected required category questionnaire, got %+v", entry.Questionnaire) + } + + if _, err := prepareUploadState(context.Background(), req, true); err == nil { + t.Fatal("expected actual upload state to still require category") + } +} + +func TestBuildDescriptionAndDryRunUseProvidedAssets(t *testing.T) { + req := cztUploadRequest(t, defaultBaseURL) + + req.Intent = trackers.PreparationIntentDryRun + plan, failure := New().Prepare(context.Background(), req) + if failure != nil { + t.Fatalf("unexpected dry-run error: %v", failure) + } + entry := plan.DryRun() + if !strings.Contains(entry.Description, "[img]https://img.example/raw.jpg[/img]") { + t.Fatalf("expected provided screenshot in dry-run description, got %q", entry.Description) + } + if !strings.Contains(entry.Payload["user_descr"], "[img]https://img.example/raw.jpg[/img]") { + t.Fatalf("expected provided screenshot in dry-run payload, got %q", entry.Payload["user_descr"]) + } + if strings.Contains(entry.Payload["user_descr"], "[url=") || strings.Contains(entry.Payload["user_descr"], "[center]") { + t.Fatalf("expected CZT screenshot BBCode to use only img tags, got %q", entry.Payload["user_descr"]) + } + + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "CZT", + Meta: req.Meta, + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: "[center]final rewritten body[/center]", + Screenshots: []api.ScreenshotImage{{ + ImgURL: "https://img.example/should-not-append.jpg", + }}, + Final: true, + }, + }) + if err != nil { + t.Fatalf("unexpected description error: %v", err) + } + if result.Description != "[center]final rewritten body[/center]" { + t.Fatalf("expected final description verbatim, got %q", result.Description) + } +} + +func TestBBCODEScreenshotBlockUsesRawURLsAndCapsAtTwo(t *testing.T) { + got := bbcodeScreenshotBlock([]api.ScreenshotImage{ + {ImgURL: "https://img.example/rehosted-only.jpg", WebURL: "https://img.example/page-only"}, + { + ImgURL: "https://img.example/rehosted-1.jpg", + WebURL: "https://img.example/page-1", + RawURL: "https://img.example/raw-1.jpg", + }, + { + ImgURL: "https://img.example/rehosted-2.jpg", + WebURL: "https://img.example/page-2", + RawURL: "https://img.example/raw-2.jpg", + }, + { + ImgURL: "https://img.example/rehosted-3.jpg", + WebURL: "https://img.example/page-3", + RawURL: "https://img.example/raw-3.jpg", + }, + }) + want := "[img]https://img.example/raw-1.jpg[/img]\n[img]https://img.example/raw-2.jpg[/img]" + if got != want { + t.Fatalf("unexpected screenshot block:\nwant %q\ngot %q", want, got) + } + if strings.Contains(got, "[url=") || strings.Contains(got, "[center]") { + t.Fatalf("expected only img BBCode, got %q", got) + } + if strings.Contains(got, "rehosted") || strings.Contains(got, "page-") || strings.Contains(got, "raw-3") { + t.Fatalf("expected raw-only first two images, got %q", got) + } +} + +func validTorrentBytes(t *testing.T) []byte { + t.Helper() + tmp := t.TempDir() + sourcePath := filepath.Join(tmp, "source.bin") + torrentPath := filepath.Join(tmp, "source.torrent") + if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + _, err := mkbrr.Create(mkbrr.CreateOptions{ + Path: sourcePath, + OutputPath: torrentPath, + IsPrivate: true, + }) + if err != nil { + t.Fatalf("create torrent: %v", err) + } + payload, err := os.ReadFile(torrentPath) + if err != nil { + t.Fatalf("read torrent: %v", err) + } + return payload +} + +type cztUploadTestServer struct { + *httptest.Server + handlerErr chan error +} + +func newCZTUploadTestServer(t *testing.T, returnedTorrent []byte, expectedPath string) *cztUploadTestServer { + t.Helper() + handlerErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := assertCZTUploadRequest(r, expectedPath); err != nil { + recordCZTUploadHandlerError(handlerErr, err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf( + w, + `{"id":123,"name":"Release","download_url":"download.php?id=123","torrent_b64":%q}`, + base64.StdEncoding.EncodeToString(returnedTorrent), + ) + })) + return &cztUploadTestServer{Server: server, handlerErr: handlerErr} +} + +func assertCZTUploadRequest(r *http.Request, expectedPath string) error { + if r.URL.Path != expectedPath { + return fmt.Errorf("expected upload path %q, got %q", expectedPath, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "" { + return errors.New("expected no authorization header") + } + if err := r.ParseMultipartForm(5 << 20); err != nil { + return fmt.Errorf("parse multipart: %w", err) + } + if got := r.FormValue("passkey"); got != "pass" { + return errors.New("expected passkey form field") + } + if got := r.FormValue("user_descr"); !strings.Contains(got, "[img]https://img.example/raw.jpg[/img]") { + return fmt.Errorf("expected raw screenshot img tag in payload, got %q", got) + } else if strings.Contains(got, "[url=") || strings.Contains(got, "[center]") || strings.Contains(got, "rehosted.jpg") || strings.Contains(got, "page") { + return fmt.Errorf("expected only raw screenshot img tags in payload, got %q", got) + } + files := r.MultipartForm.File["file"] + if len(files) != 1 { + return fmt.Errorf("expected one torrent file, got %d", len(files)) + } + file, err := files[0].Open() + if err != nil { + return fmt.Errorf("open multipart file: %w", err) + } + defer func() { + _ = file.Close() + }() + if _, err := io.ReadAll(file); err != nil { + return fmt.Errorf("read multipart file: %w", err) + } + return nil +} + +func recordCZTUploadHandlerError(handlerErr chan<- error, err error) { + select { + case handlerErr <- err: + default: + } +} + +func (s *cztUploadTestServer) assertNoHandlerError(t *testing.T) { + t.Helper() + if err := s.handlerError(); err != nil { + t.Fatalf("upload handler assertion: %v", err) + } +} + +func (s *cztUploadTestServer) assertHandlerErrorContains(t *testing.T, want string) { + t.Helper() + err := s.handlerError() + if err == nil { + t.Fatal("expected upload handler assertion error") + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected upload handler assertion containing %q, got %v", want, err) + } +} + +func (s *cztUploadTestServer) handlerError() error { + select { + case err := <-s.handlerErr: + return err + default: + return nil + } +} + +func cztUploadRequest(t *testing.T, trackerURL string) trackers.PreparationInput { + t.Helper() + if strings.TrimSpace(trackerURL) != "" { + withCZTBaseURL(t, trackerURL) + } + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "Release.torrent") + if err := os.WriteFile(torrentPath, []byte("source-torrent"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + sourcePath := filepath.Join(tmp, "Release.mkv") + if err := os.WriteFile(sourcePath, []byte("video"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + return trackers.PreparationInput{ + Tracker: "CZT", + Meta: api.UploadSubject{ + SourcePath: sourcePath, + TorrentPath: torrentPath, + ReleaseName: "Release.2026.1080p.WEB-DL", + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + TrackerConfig: config.TrackerConfig{ + Passkey: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "state", "upbrr.db")}}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: "kept description", + Screenshots: []api.ScreenshotImage{{ + ImgURL: "https://img.example/rehosted.jpg", + WebURL: "https://img.example/page", + RawURL: "https://img.example/raw.jpg", + }, { + ImgURL: "https://img.example/rehosted-2.jpg", + WebURL: "https://img.example/page-2", + RawURL: "https://img.example/raw-2.jpg", + }, { + ImgURL: "https://img.example/rehosted-3.jpg", + WebURL: "https://img.example/page-3", + RawURL: "https://img.example/raw-3.jpg", + }}, + }, + } +} + +func withCZTBaseURL(t *testing.T, baseURL string) { + t.Helper() + old := cztBaseURL + cztBaseURL = baseURL + t.Cleanup(func() { + cztBaseURL = old + }) +} diff --git a/internal/trackers/impl/standalone/dc/bbcode.go b/internal/trackers/impl/standalone/dc/bbcode.go new file mode 100644 index 00000000..6a8e03e2 --- /dev/null +++ b/internal/trackers/impl/standalone/dc/bbcode.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dc + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.RemoveSup(value) + value = bbcode.RemoveSub(value) + value = bbcode.ConvertNamedSpoilerToNormalSpoiler(value) + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/dc/dupe.go b/internal/trackers/impl/standalone/dc/dupe.go new file mode 100644 index 00000000..754847ee --- /dev/null +++ b/internal/trackers/impl/standalone/dc/dupe.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dc + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/internal/jsondupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: "https://digitalcore.club/api/v1/torrents", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := dcAPIKey(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb id for DC dupe search", nil) + } + return jsondupe.Search(ctx, s.http, jsondupe.ListSpec{ + Endpoint: s.endpoint, + Query: url.Values{"searchText": {"tt" + strconv.Itoa(meta.Identity.IMDBID)}}, + Headers: http.Header{"X-Api-Key": {apiKey}}, + IDField: "id", + NameField: "name", + SizeField: "size", + Link: func(id string) string { return "https://digitalcore.club/torrent/" + id + "/" }, + FailureMessage: "DC search failed", + }) +} + +func dcAPIKey(cfg config.Config) string { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "DC") { + return strings.TrimSpace(entry.APIKey) + } + } + return "" +} diff --git a/internal/trackers/impl/standalone/dc/dupe_test.go b/internal/trackers/impl/standalone/dc/dupe_test.go new file mode 100644 index 00000000..adaf0359 --- /dev/null +++ b/internal/trackers/impl/standalone/dc/dupe_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDuplicateSearchUsesDCQueryHeadersAndProjection(t *testing.T) { + requestErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("searchText") != "tt1234567" || r.Header.Get("X-Api-Key") != "secret" { + requestErr <- errors.New("unexpected DC duplicate request shape") + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = w.Write([]byte(`[{"id":42,"name":"Example.Release.2026.1080p-GRP","size":1234}]`)) + })) + defer server.Close() + + searcher := &dupeSearcher{ + cfg: config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "DC": {APIKey: "secret"}, + }}}, + http: server.Client(), + endpoint: server.URL, + } + result := searcher.Search(context.Background(), api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 1234567}}) + select { + case err := <-requestErr: + t.Fatal(err) + default: + } + if result.Disposition() != dupe.DispositionResolved { + t.Fatalf("unexpected disposition=%v code=%q cause=%v", result.Disposition(), result.Code(), result.Cause()) + } + entries := result.Entries() + if len(entries) != 1 || entries[0].ID != "42" || entries[0].Link != "https://digitalcore.club/torrent/42/" || entries[0].SizeBytes != 1234 { + t.Fatalf("unexpected entries: %#v", entries) + } +} diff --git a/internal/trackers/impl/standalone/dc/profile.go b/internal/trackers/impl/standalone/dc/profile.go new file mode 100644 index 00000000..42d59de0 --- /dev/null +++ b/internal/trackers/impl/standalone/dc/profile.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dc + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns DC identity, preparation, dupe, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "DC", + BaseURL: baseURL, + DescriptionGroup: "dc", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: sourceFlag, + }, + AudioPolicy: &trackers.AudioPolicy{ + AllowBloat: true, + }, + ImageHostPolicy: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"imgbox", "imgbb", "bhd", "imgur", "postimg", "sharex"}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"tracker.digitalcore.club", "trackerprxy.digitalcore.club"}, + }, + } +} + +// New returns a fresh DC definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "dc", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/dc/upload.go b/internal/trackers/impl/standalone/dc/upload.go new file mode 100644 index 00000000..cfcf523a --- /dev/null +++ b/internal/trackers/impl/standalone/dc/upload.go @@ -0,0 +1,403 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://digitalcore.club" + apiBaseURL = baseURL + "/api/v1/torrents" + sourceFlag = "DigitalCore.club" +) + +type uploadState struct { + torrentPath string + releaseName string + description string + mediaInfo string + fields map[string]string + blockedReason string +} + +type uploadResponse struct { + ID any `json:"id"` + Message string `json:"message"` +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: DC %s", state.blockedReason) + } + + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ + FieldName: "file", + FileName: state.releaseName + ".torrent", + Path: state.torrentPath, + }}) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + artifactPath := "" + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "DC") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType, apiKey, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, + apiKey string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/upload", strings.NewReader(string(body))) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: DC request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + httpReq.Header.Set("X-Api-Key", apiKey) + + result, err := commonhttp.ExecuteUpload(httpclient.New(httpclient.DefaultTimeout), httpReq, commonhttp.UploadExecutionOptions{Tracker: "DC"}) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: DC execute upload: %w", err) + } + var decoded uploadResponse + if len(result.Body) > 0 { + if err := json.Unmarshal(result.Body, &decoded); err != nil { + if !result.Success { + return api.UploadSummary{}, commonhttp.UploadHTTPError("DC", result.StatusCode, result.Preview) + } + return api.UploadSummary{}, fmt.Errorf("trackers: DC decode response: %s", commonhttp.RedactErrorDetail(err.Error())) + } + } + torrentID := strings.TrimSpace(fmt.Sprint(decoded.ID)) + if result.Success && torrentID != "" && torrentID != "" { + torrentURL := baseURL + "/torrent/" + torrentID + "/" + downloadURL := apiBaseURL + "/download/" + torrentID + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "DC", + TorrentID: torrentID, + TorrentURL: torrentURL, + DownloadURL: downloadURL, + TorrentPath: artifactPath, + }}, + }, nil + } + + if _, artifactErr := commonhttp.WriteFailureArtifact( + req.Meta, + req.Runtime.DBPath, + "DC", + "upload_failure", + result.Preview, + ".json", + ); artifactErr != nil && + req.Logger != nil { + req.Logger.Warnf("trackers: DC failure artifact write failed: %v", artifactErr) + } + message := metautil.FirstNonEmptyTrimmed( + commonhttp.ExtractHTTPErrorDetail(result.Preview), + commonhttp.RedactErrorDetail(decoded.Message), + commonhttp.RedactErrorDetail(string(result.Preview)), + "upload failed", + ) + return api.UploadSummary{}, fmt.Errorf("trackers: DC %s", message) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "DC", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "dc", + Description: state.description, + Endpoint: apiBaseURL + "/upload", + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(_ context.Context, req trackers.PreparationInput) (uploadState, error) { + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: DC missing api_key") + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + mediaInfo, err := resolveMediaInfo(req.Meta) + if err != nil { + return uploadState{}, err + } + releaseName := resolveUploadName(req.Meta) + fields := map[string]string{ + "category": strconv.Itoa(resolveCategoryID(req.Meta)), + "imdbId": resolveIMDbID(req.Meta), + "nfo": description, + "mediainfo": mediaInfo, + "reqid": "0", + "section": "new", + "frileech": "1", + "anonymousUpload": resolveAnon(req), + "p2p": "0", + "unrar": "1", + } + state := uploadState{ + torrentPath: torrentPath, + releaseName: releaseName, + description: description, + mediaInfo: mediaInfo, + fields: fields, + } + if strings.TrimSpace(fields["imdbId"]) == "" { + state.blockedReason = "missing IMDb ID" + } + return state, nil +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + meta := req.Meta + var parts []string + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // TV Episode details + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") + } + + // File information + if media := resolveMedia(req, meta); media != "" { + parts = append(parts, strings.TrimSpace(media)) + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // Combined Screenshots + allShots := make([]api.ScreenshotImage, 0, len(assets.MenuImages)+len(assets.Screenshots)) + allShots = append(allShots, assets.MenuImages...) + allShots = append(allShots, assets.Screenshots...) + if shots := screenshotBlock(allShots); shots != "" { + parts = append(parts, shots) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // Signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[center][url=%s]%s[/url][/center]", link, text)) + + // Join and finalize + description := strings.Join(parts, "\n\n") + finalized := finalizeDescription(description) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "DC", req.Runtime.DBPath, finalized, req.Logger) + } + + return finalized +} + +func resolveCategoryID(meta api.UploadSubject) int { + resolution := strings.ToLower(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Resolution, meta.ReleaseNameNoTag, meta.ReleaseName))) + category := categoryOf(meta) + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + if category == "TV" { + return 14 + } + if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "2160p") { + return 38 + } + return 3 + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + if category == "TV" { + return 11 + } + return 1 + } + if category == "TV" && meta.TVPack { + return 12 + } + if isSD(meta) { + if category == "TV" { + return 10 + } + return 2 + } + switch category { + case "TV": + switch strings.TrimSpace(meta.Release.Resolution) { + case "2160p": + return 13 + case "1080p", "1080i": + return 9 + default: + return 8 + } + default: + switch strings.TrimSpace(meta.Release.Resolution) { + case "2160p": + return 4 + case "1080p", "1080i": + return 6 + default: + _ = resolution + return 5 + } + } +} + +func resolveUploadName(meta api.UploadSubject) string { + name := metautil.FirstNonEmptyTrimmed( + strings.TrimSpace(meta.SceneName), + strings.TrimSpace(meta.ReleaseNameClean), + strings.TrimSpace(meta.ReleaseName), + strings.TrimSpace(meta.Filename), + ) + if name == "" { + name = "release" + } + if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { + return strings.TrimSpace(meta.SceneName) + " [UNRAR]" + } + name = strings.NewReplacer("DD+", "DDP", "DTS:", "DTS-", "HDR10+", "HDR10P").Replace(name) + out := strings.Builder{} + for _, r := range name { + if r > 127 { + continue + } + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' || r == '.' || r == '-' { + out.WriteRune(r) + } + } + return strings.TrimSpace(out.String()) +} + +func resolveMediaInfo(meta api.UploadSubject) (string, error) { + if text := metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)); text != "" { + return text, nil + } + return "", errors.New("trackers: DC missing mediainfo") +} + +func resolveMedia(req trackers.PreparationInput, meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + bdinfo, _ := trackers.ReadBDInfo(req.Runtime.DBPath, meta) + return strings.TrimSpace(bdinfo) + } + return metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)) +} + +func resolveIMDbID(meta api.UploadSubject) string { + if meta.Identity.IMDBID > 0 { + return "tt" + strconv.Itoa(meta.Identity.IMDBID) + } + return "" +} + +func resolveAnon(req trackers.PreparationInput) string { + if req.TrackerConfig.Anon { + return "1" + } + return "0" +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := make([]string, 0, len(images)) + for _, image := range images { + if strings.TrimSpace(image.WebURL) == "" || strings.TrimSpace(image.RawURL) == "" { + continue + } + parts = append(parts, "[url="+strings.TrimSpace(image.WebURL)+"][img=350]"+strings.TrimSpace(image.RawURL)+"[/img][/url]") + } + if len(parts) == 0 { + return "" + } + return "[center]" + strings.Join(parts, " ") + "[/center]" +} + +func isSD(meta api.UploadSubject) bool { + resolution := strings.TrimSpace(meta.Release.Resolution) + return resolution == "480p" || resolution == "576p" || resolution == "" +} diff --git a/internal/trackers/impl/standalone/definition.go b/internal/trackers/impl/standalone/definition.go new file mode 100644 index 00000000..cce59706 --- /dev/null +++ b/internal/trackers/impl/standalone/definition.go @@ -0,0 +1,262 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +// Definition implements shared standalone identity, preparation, duplicate, +// and declarative capability contracts from one immutable profile. +type Definition struct { + profile Profile +} + +// New validates, normalizes, and defensively copies a standalone profile. +func New(profile Profile) (*Definition, error) { + profile.Name = strings.ToUpper(strings.TrimSpace(profile.Name)) + profile.BaseURL = strings.TrimRight(strings.TrimSpace(profile.BaseURL), "/") + profile.DescriptionGroup = strings.ToLower(strings.TrimSpace(profile.DescriptionGroup)) + profile.LocalizedMetadataLocale = strings.TrimSpace(profile.LocalizedMetadataLocale) + if profile.DescriptionGroup == "" { + profile.DescriptionGroup = strings.ToLower(profile.Name) + } + switch { + case profile.Name == "": + return nil, errors.New("standalone tracker profile has empty name") + case profile.BaseURL == "": + return nil, fmt.Errorf("standalone tracker profile %s has empty base URL", profile.Name) + case !profile.UploadContentMode.Valid(): + return nil, fmt.Errorf("standalone tracker profile %s has invalid upload content mode %q", profile.Name, profile.UploadContentMode) + case profile.UploadContentMode.UsesDescription() && profile.PrepareDescription == nil: + return nil, fmt.Errorf("standalone tracker profile %s has no description preparer", profile.Name) + case profile.PrepareUpload == nil: + return nil, fmt.Errorf("standalone tracker profile %s has no upload preparer", profile.Name) + case profile.NewDuplicateAdapter == nil: + return nil, fmt.Errorf("standalone tracker profile %s has no duplicate adapter", profile.Name) + } + profile = cloneProfile(profile) + if profile.AuthCapability != nil { + profile.AuthCapability.TrackerID = profile.Name + if strings.TrimSpace(profile.AuthCapability.DisplayName) == "" { + profile.AuthCapability.DisplayName = profile.Name + } + } + return &Definition{profile: profile}, nil +} + +// MustNew returns a validated standalone definition or panics for an invalid +// compiled profile. +func MustNew(profile Profile) *Definition { + definition, err := New(profile) + if err != nil { + panic(err) + } + return definition +} + +// Name returns the normalized tracker identifier. +func (d *Definition) Name() string { return d.profile.Name } + +// TrackerFamily identifies the definition as standalone. +func (*Definition) TrackerFamily() trackers.Family { return trackers.FamilyStandalone } + +// DefaultBaseURL returns the profile-owned tracker endpoint. +func (d *Definition) DefaultBaseURL() string { return d.profile.BaseURL } + +// DescriptionGroup returns the profile-owned description override group. +func (d *Definition) DescriptionGroup() string { return d.profile.DescriptionGroup } + +// LocalizedMetadataLocale returns the optional tracker-owned metadata locale. +func (d *Definition) LocalizedMetadataLocale() string { return d.profile.LocalizedMetadataLocale } + +// UploadContentMode returns the profile-owned shared content workflow. +func (d *Definition) UploadContentMode() trackers.UploadContentMode { + return d.profile.UploadContentMode +} + +// Prepare dispatches intent through the tracker-local profile callbacks. +func (d *Definition) Prepare(ctx context.Context, input trackers.PreparationInput) (trackers.TrackerPlan, *trackers.PreparationFailure) { + if input.Intent == trackers.PreparationIntentDescriptionPreview && !d.profile.UploadContentMode.UsesDescription() { + return trackers.TrackerPlan{}, trackers.NewPreparationFailure( + input.Tracker, + "capability", + "tracker does not support shared description preparation", + nil, + ) + } + return trackers.PrepareAdapter(ctx, input, d.profile.PrepareDescription, d.profile.PrepareUpload) +} + +// NewDuplicateAdapter builds the tracker-local duplicate-search adapter. +func (d *Definition) NewDuplicateAdapter(dependencies dupe.Dependencies) dupe.Adapter { + return d.profile.NewDuplicateAdapter(dependencies) +} + +// Rules returns a defensive copy of tracker-owned validation rules. +func (d *Definition) Rules() *trackers.RuleSet { return cloneRules(d.profile.Rules) } + +// ClaimPolicy returns tracker-owned claim orchestration policy. +func (d *Definition) ClaimPolicy() *trackers.ClaimPolicy { return cloneValue(d.profile.ClaimPolicy) } + +// DataLookupPolicy returns tracker-owned lookup orchestration policy. +func (d *Definition) DataLookupPolicy() *trackers.DataLookupPolicy { + return cloneValue(d.profile.DataPolicy) +} + +// ArtifactPolicy returns tracker-owned torrent artifact limits. +func (d *Definition) ArtifactPolicy() *trackers.ArtifactPolicy { + return cloneValue(d.profile.ArtifactPolicy) +} + +// BannedGroups returns a defensive copy of static tracker bans. +func (d *Definition) BannedGroups() []string { return slices.Clone(d.profile.BannedGroups) } + +// BannedGroupPolicy returns tracker-owned dynamic ban retrieval settings. +func (d *Definition) BannedGroupPolicy() *trackers.BannedGroupPolicy { + return cloneValue(d.profile.BannedGroupPolicy) +} + +// MetadataPolicy returns a defensive copy of tracker metadata requirements. +func (d *Definition) MetadataPolicy() *trackers.TrackerMetadataPolicy { + return cloneMetadataPolicy(d.profile.MetadataPolicy) +} + +// UploadArtifactPolicy returns tracker torrent personalization settings. +func (d *Definition) UploadArtifactPolicy() *trackers.UploadArtifactPolicy { + return cloneValue(d.profile.UploadArtifactPolicy) +} + +// DupePolicy returns tracker-specific duplicate comparison settings. +func (d *Definition) DupePolicy() *trackers.DupePolicy { return cloneValue(d.profile.DupePolicy) } + +// AudioPolicy returns a defensive copy of tracker audio constraints. +func (d *Definition) AudioPolicy() *trackers.AudioPolicy { + return cloneAudioPolicy(d.profile.AudioPolicy) +} + +// ImageHostPolicy returns a defensive copy of tracker image-host constraints. +func (d *Definition) ImageHostPolicy() *trackers.ImageHostPolicy { + return cloneImageHostPolicy(d.profile.ImageHostPolicy) +} + +// TorrentIdentityPolicy returns a defensive copy of tracker identity patterns. +func (d *Definition) TorrentIdentityPolicy() *trackers.TorrentIdentityPolicy { + return cloneTorrentIdentityPolicy(d.profile.TorrentIdentityPolicy) +} + +// AuthCapabilityDescriptor returns optional profile-owned auth metadata. +func (d *Definition) AuthCapabilityDescriptor() *api.TrackerAuthCapability { + return cloneAuthCapability(d.profile.AuthCapability) +} + +// AuthSessionResolver returns optional tracker-owned auth resolution behavior. +func (d *Definition) AuthSessionResolver() trackers.AuthSessionResolver { + return d.profile.AuthResolver +} + +// AuthPolicy returns optional tracker-owned auth coordinator settings. +func (d *Definition) AuthPolicy() *trackers.AuthPolicy { return cloneValue(d.profile.AuthPolicy) } + +// AuthStateManager returns optional tracker-owned persisted auth management. +func (d *Definition) AuthStateManager() trackers.AuthStateManager { return d.profile.AuthStateManager } + +func cloneProfile(profile Profile) Profile { + profile.Rules = cloneRules(profile.Rules) + profile.ClaimPolicy = cloneValue(profile.ClaimPolicy) + profile.DataPolicy = cloneValue(profile.DataPolicy) + profile.ArtifactPolicy = cloneValue(profile.ArtifactPolicy) + profile.BannedGroups = slices.Clone(profile.BannedGroups) + profile.BannedGroupPolicy = cloneValue(profile.BannedGroupPolicy) + profile.MetadataPolicy = cloneMetadataPolicy(profile.MetadataPolicy) + profile.UploadArtifactPolicy = cloneValue(profile.UploadArtifactPolicy) + profile.DupePolicy = cloneValue(profile.DupePolicy) + profile.AudioPolicy = cloneAudioPolicy(profile.AudioPolicy) + profile.ImageHostPolicy = cloneImageHostPolicy(profile.ImageHostPolicy) + profile.TorrentIdentityPolicy = cloneTorrentIdentityPolicy(profile.TorrentIdentityPolicy) + profile.AuthCapability = cloneAuthCapability(profile.AuthCapability) + profile.AuthPolicy = cloneValue(profile.AuthPolicy) + return profile +} + +func cloneRules(rules *trackers.RuleSet) *trackers.RuleSet { + if rules == nil { + return nil + } + clone := *rules + clone.RequireHEVCForTypes = slices.Clone(rules.RequireHEVCForTypes) + clone.BlockGroups = slices.Clone(rules.BlockGroups) + clone.BlockGroupUnlessType = make(map[string][]string, len(rules.BlockGroupUnlessType)) + for group, releaseTypes := range rules.BlockGroupUnlessType { + clone.BlockGroupUnlessType[group] = slices.Clone(releaseTypes) + } + if rules.Language != nil { + language := *rules.Language + language.Languages = slices.Clone(rules.Language.Languages) + clone.Language = &language + } + return &clone +} + +func cloneMetadataPolicy(policy *trackers.TrackerMetadataPolicy) *trackers.TrackerMetadataPolicy { + if policy == nil { + return nil + } + clone := *policy + clone.Requirements = slices.Clone(policy.Requirements) + for idx := range clone.Requirements { + clone.Requirements[idx].AnyOf = slices.Clone(clone.Requirements[idx].AnyOf) + } + return &clone +} + +func cloneAudioPolicy(policy *trackers.AudioPolicy) *trackers.AudioPolicy { + clone := cloneValue(policy) + if clone != nil { + clone.AllowedLanguages = slices.Clone(policy.AllowedLanguages) + } + return clone +} + +func cloneImageHostPolicy(policy *trackers.ImageHostPolicy) *trackers.ImageHostPolicy { + clone := cloneValue(policy) + if clone != nil { + clone.AllowedHosts = slices.Clone(policy.AllowedHosts) + clone.OwnedHosts = slices.Clone(policy.OwnedHosts) + } + return clone +} + +func cloneTorrentIdentityPolicy(policy *trackers.TorrentIdentityPolicy) *trackers.TorrentIdentityPolicy { + clone := cloneValue(policy) + if clone != nil { + clone.TrackerURLPatterns = slices.Clone(policy.TrackerURLPatterns) + clone.CommentURLPatterns = slices.Clone(policy.CommentURLPatterns) + } + return clone +} + +func cloneAuthCapability(capability *api.TrackerAuthCapability) *api.TrackerAuthCapability { + clone := cloneValue(capability) + if clone != nil { + clone.Notes = slices.Clone(capability.Notes) + } + return clone +} + +func cloneValue[T any](value *T) *T { + if value == nil { + return nil + } + clone := *value + return &clone +} diff --git a/internal/trackers/impl/standalone/definition_test.go b/internal/trackers/impl/standalone/definition_test.go new file mode 100644 index 00000000..305b5682 --- /dev/null +++ b/internal/trackers/impl/standalone/definition_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "context" + "testing" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type profileDupeAdapter struct{} + +func (profileDupeAdapter) Search(context.Context, api.DuplicateSubject) dupe.AdapterResult { + return dupe.NotRun(dupe.NotRunManualCheckRequired, "test", nil) +} + +func validProfile() Profile { + return Profile{ + Name: " dc ", + BaseURL: "https://tracker.invalid/", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: func(context.Context, trackers.PreparationInput) (trackers.DescriptionResult, error) { + return trackers.DescriptionResult{Group: "dc"}, nil + }, + PrepareUpload: func(context.Context, trackers.PreparationInput) (trackers.PreparedOperation, error) { + return trackers.NewPreparedOperation(api.TrackerDryRunEntry{Tracker: "DC"}, nil, nil), nil + }, + NewDuplicateAdapter: func(dupe.Dependencies) dupe.Adapter { return profileDupeAdapter{} }, + } +} + +func TestNewDefinitionNormalizesAndDefensivelyCopiesProfile(t *testing.T) { + t.Parallel() + + profile := validProfile() + profile.BannedGroups = []string{"GRP"} + profile.AuthCapability = &api.TrackerAuthCapability{Notes: []string{"configured"}} + definition, err := New(profile) + if err != nil { + t.Fatalf("new definition: %v", err) + } + profile.BannedGroups[0] = "mutated" + profile.AuthCapability.Notes[0] = "mutated" + if definition.Name() != "DC" || definition.DefaultBaseURL() != "https://tracker.invalid" || definition.TrackerFamily() != trackers.FamilyStandalone { + t.Fatalf("definition identity = %q %q %q", definition.Name(), definition.DefaultBaseURL(), definition.TrackerFamily()) + } + if definition.DescriptionGroup() != "dc" || definition.BannedGroups()[0] != "GRP" { + t.Fatalf("definition profile = %q %#v", definition.DescriptionGroup(), definition.BannedGroups()) + } + capability := definition.AuthCapabilityDescriptor() + if capability == nil || capability.TrackerID != "DC" || capability.Notes[0] != "configured" { + t.Fatalf("auth capability = %#v", capability) + } +} + +func TestNewDefinitionRejectsMissingRequiredProfileFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Profile) + }{ + {name: "name", mutate: func(profile *Profile) { profile.Name = "" }}, + {name: "base URL", mutate: func(profile *Profile) { profile.BaseURL = "" }}, + {name: "description", mutate: func(profile *Profile) { profile.PrepareDescription = nil }}, + {name: "upload", mutate: func(profile *Profile) { profile.PrepareUpload = nil }}, + {name: "duplicate", mutate: func(profile *Profile) { profile.NewDuplicateAdapter = nil }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + profile := validProfile() + test.mutate(&profile) + if _, err := New(profile); err == nil { + t.Fatal("expected profile validation error") + } + }) + } +} + +func TestCookieAuthCapabilityNormalizesTrackerID(t *testing.T) { + capability := CookieAuthCapability(" tl ") + if capability.TrackerID != "TL" || capability.DisplayName != "TL" || capability.AuthKind != "cookies" || !capability.SupportsCookieFile { + t.Fatalf("unexpected capability: %#v", capability) + } +} + +func TestDefinitionLeavesUndeclaredAuthCapabilityAbsent(t *testing.T) { + definition := MustNew(validProfile()) + if capability := definition.AuthCapabilityDescriptor(); capability != nil { + t.Fatalf("expected absent auth capability, got %#v", capability) + } +} diff --git a/internal/trackers/impl/standalone/ff/auth.go b/internal/trackers/impl/standalone/ff/auth.go new file mode 100644 index 00000000..c33afa72 --- /dev/null +++ b/internal/trackers/impl/standalone/ff/auth.go @@ -0,0 +1,156 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ff + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" +) + +const ffAuthResponseMaxBytes = 1 << 20 + +// validateAuthCookies checks bounded FF upload-page evidence. Explicit login +// evidence and a missing authenticated-page marker are confirmed-invalid; +// transport, read, and other HTTP failures remain transient. +func validateAuthCookies(ctx context.Context, baseURL string, values []*http.Cookie) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/upload.php", nil) + if err != nil { + return fmt.Errorf("trackers: FF session validation request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + for _, cookie := range values { + if cookie != nil && strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + req.AddCookie(cookie) + } + } + resp, err := ffAuthHTTPClient().Do(req) + if err != nil { + return &trackerauth.ValidationError{ + TrackerID: "FF", + Transient: true, + Reason: "remote validation unavailable", + Err: fmt.Errorf("trackers: FF session validation request: %w", err), + } + } + defer resp.Body.Close() + body, err := readFFAuthBody(resp) + if err != nil { + return &trackerauth.ValidationError{ + TrackerID: "FF", + Transient: true, + Reason: "remote validation unavailable", + Err: err, + } + } + lower := strings.ToLower(string(body)) + location := strings.ToLower(resp.Header.Get("Location")) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || strings.Contains(location, "login") || + strings.Contains(lower, "takelogin.php") || + strings.Contains(lower, "name=\"username\"") || + strings.Contains(lower, "name=\"password\"") { + return &trackerauth.ValidationError{ + TrackerID: "FF", + ConfirmedInvalid: true, + Reason: "stored session expired", + Err: fmt.Errorf("trackers: FF session validation failed status=%d", resp.StatusCode), + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &trackerauth.ValidationError{ + TrackerID: "FF", + Transient: true, + Reason: "remote validation failed", + Err: fmt.Errorf("trackers: FF session validation failed status=%d", resp.StatusCode), + } + } + if !strings.Contains(lower, "friends.php") { + return &trackerauth.ValidationError{ + TrackerID: "FF", + ConfirmedInvalid: true, + Reason: "stored session expired", + Err: errors.New("trackers: FF login marker not found"), + } + } + return nil +} + +// loginAuthSession logs in with configured credentials and persists only +// non-empty cookies that pass the same remote session validation. +func loginAuthSession(ctx context.Context, cfg config.TrackerConfig, dbPath string, baseURL string) error { + data := url.Values{ + "returnto": {"/index.php"}, + "username": {strings.TrimSpace(cfg.Username)}, + "password": {strings.TrimSpace(cfg.Password)}, + "login": {"Login"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/takelogin.php", strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("trackers: FF login request build: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "upbrr") + resp, err := ffAuthHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("trackers: FF login request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + return &trackerauth.ValidationError{ + TrackerID: "FF", + ConfirmedInvalid: true, + Reason: "login failed", + Err: fmt.Errorf("trackers: FF login failed status=%d", resp.StatusCode), + } + } + loginCookies := usableFFAuthCookies(resp.Cookies()) + if len(loginCookies) == 0 { + return errors.New("trackers: FF login returned no usable cookies") + } + if err := validateAuthCookies(ctx, baseURL, loginCookies); err != nil { + return fmt.Errorf("trackers: FF validate login cookies: %w", err) + } + if err := cookies.SaveTrackerHTTPCookies(ctx, dbPath, "FF", loginCookies); err != nil { + return fmt.Errorf("trackers: FF persist login cookies: %w", err) + } + return nil +} + +func ffAuthHTTPClient() *http.Client { + return &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} +} + +func readFFAuthBody(resp *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, ffAuthResponseMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("trackers: FF read auth response: %w", err) + } + if len(body) > ffAuthResponseMaxBytes { + return nil, errors.New("trackers: FF auth response exceeds limit") + } + return body, nil +} + +func usableFFAuthCookies(values []*http.Cookie) []*http.Cookie { + usable := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + if cookie != nil && strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + usable = append(usable, cookie) + } + } + return usable +} + +func ffHasLoginCredentials(cfg config.TrackerConfig) bool { + return strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "" +} diff --git a/internal/trackers/impl/standalone/ff/bbcode.go b/internal/trackers/impl/standalone/ff/bbcode.go new file mode 100644 index 00000000..0373e149 --- /dev/null +++ b/internal/trackers/impl/standalone/ff/bbcode.go @@ -0,0 +1,87 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ff + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +var ( + urlSizedImagePattern = regexp.MustCompile(`(?i)\[url=(?P[^\]]+)\]\[img=(?P\d+)\](?P[^\[]+)\[/img\]\[/url\]`) + urlImagePattern = regexp.MustCompile(`(?i)\[url=(?P[^\]]+)\]\[img\](?P[^\[]+)\[/img\]\[/url\]`) + sizedImagePattern = regexp.MustCompile(`(?i)\[img=(?P\d+)\](?P[^\[]+)\[/img\]`) +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = strings.NewReplacer( + "[user]", + "", + "[/user]", + "", + "[align=left]", + "", + "[/align]", + "", + "[right]", + "", + "[/right]", + "", + "[align=right]", + "", + "[alert]", + "", + "[/alert]", + "", + "[note]", + "", + "[/note]", + "", + "[hr]", + "", + "[/hr]", + "", + "[h1]", + "[u][b]", + "[/h1]", + "[/b][/u]", + "[h2]", + "[u][b]", + "[/h2]", + "[/b][/u]", + "[h3]", + "[u][b]", + "[/h3]", + "[/b][/u]", + "[ul]", + "", + "[/ul]", + "", + "[ol]", + "", + "[/ol]", + "", + "[hide]", + "", + "[/hide]", + "", + "•", + "-", + "“", + `"`, + "”", + `"`, + ).Replace(value) + value = bbcode.RemoveSub(value) + value = bbcode.RemoveSup(value) + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveSpoiler(value) + value = urlSizedImagePattern.ReplaceAllString(value, `
`) + value = urlImagePattern.ReplaceAllString(value, ``) + value = sizedImagePattern.ReplaceAllString(value, ``) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/ff/dupe.go b/internal/trackers/impl/standalone/ff/dupe.go new file mode 100644 index 00000000..092eee10 --- /dev/null +++ b/internal/trackers/impl/standalone/ff/dupe.go @@ -0,0 +1,103 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ff + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +var ffGroupPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)`) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if !meta.Anime && meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for FF dupe search", nil) + } + baseURL := ffBaseURL(s.cfg) + parsed, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "FF search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "FF", parsed.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid FF cookies", nil) + } + query := fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + if meta.Anime { + query = metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName) + } + status, root, err := commonhttp.GetHTML(ctx, s.http, baseURL+"/torrents.php", url.Values{"searchstr": {query}}, trackerCookies) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices || root == nil { + return dupe.Failed(dupe.FailureRequest, "FF search failed", err) + } + groupLinks := commonhttp.FindNodes(root, func(node *xhtml.Node) bool { + href := commonhttp.Attr(node, "href") + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(href, "torrents.php?id=") && !strings.Contains(href, "torrentid") + }) + seen := make(map[string]struct{}, len(groupLinks)) + entries := make([]api.DupeEntry, 0) + for _, linkNode := range groupLinks { + groupLink := commonhttp.AbsoluteURL(baseURL, commonhttp.Attr(linkNode, "href")) + if groupLink == "" { + continue + } + if _, ok := seen[groupLink]; ok { + continue + } + seen[groupLink] = struct{}{} + groupStatus, groupRoot, getErr := commonhttp.GetHTML(ctx, s.http, groupLink, nil, trackerCookies) + if getErr != nil || groupStatus < http.StatusOK || groupStatus >= http.StatusMultipleChoices || groupRoot == nil { + continue + } + for _, row := range commonhttp.FindNodes(groupRoot, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "tr" && strings.HasPrefix(commonhttp.Attr(node, "id"), "torrent") + }) { + link := commonhttp.FirstNode(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(commonhttp.Attr(node, "onclick"), "gtoggle") + }) + if link == nil { + continue + } + entry := api.DupeEntry{Name: strings.TrimSpace(commonhttp.NodeText(link)), Link: groupLink} + if match := ffGroupPattern.FindStringSubmatch(groupLink); len(match) == 2 { + entry.ID = match[1] + } + if entry.Name != "" { + entries = append(entries, entry) + } + } + } + return dupe.Resolved(entries, nil) +} + +func ffBaseURL(_ config.Config) string { + return "https://www.funfile.org" +} diff --git a/internal/trackers/impl/standalone/ff/profile.go b/internal/trackers/impl/standalone/ff/profile.go new file mode 100644 index 00000000..ece09a1f --- /dev/null +++ b/internal/trackers/impl/standalone/ff/profile.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ff + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/internal/cookieauth" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns FF identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "FF", + BaseURL: baseURL, + DescriptionGroup: "ff", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: sourceFlag, + }, + AudioPolicy: &trackers.AudioPolicy{ + AllowBloat: true, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"tracker.funfile.org"}, + }, + AuthCapability: &api.TrackerAuthCapability{ + AuthKind: "cookies_login", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + }, + AuthResolver: cookieauth.CookieLoginResolver(cookieauth.CookieLoginSpec{ + TrackerID: "FF", + BaseURL: baseURL, + CookieDomain: "www.funfile.org", + Validate: validateAuthCookies, + HasCredential: ffHasLoginCredentials, + Login: loginAuthSession, + }), + } +} + +// New returns a fresh FF definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + return trackers.DescriptionResult{Group: "ff", Description: buildDescription(assets)}, nil +} diff --git a/internal/trackers/impl/standalone/ff/upload.go b/internal/trackers/impl/standalone/ff/upload.go new file mode 100644 index 00000000..de2e71a8 --- /dev/null +++ b/internal/trackers/impl/standalone/ff/upload.go @@ -0,0 +1,431 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ff + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://www.funfile.org" + loginURL = baseURL + "/takelogin.php" + uploadURL = baseURL + "/takeupload.php" + torrentURL = baseURL + "/details.php?id=" + sourceFlag = "FunFile" +) + +var idPattern = regexp.MustCompile(`details\.php\?id=(\d+)`) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + extraFiles []commonhttp.FileField + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: FF %s", state.blockedReason) + } + files := append([]commonhttp.FileField{{ + FieldName: "file", + FileName: state.releaseName + ".torrent", + Path: state.torrentPath, + }}, state.extraFiles...) + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + artifactPath := "" + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "FF") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: FF request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + client := httpclient.CloneWithTimeout( + &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, + httpclient.DefaultTimeout, + ) + result, err := commonhttp.ExecuteUpload(client, httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "FF", + SuccessStatus: func(status int) bool { return status == http.StatusFound }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: FF execute upload: %w", err) + } + location := result.Header.Get("Location") + match := idPattern.FindStringSubmatch(location) + if result.Success && len(match) >= 2 { + id := match[1] + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "FF", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}}, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "FF", "upload_failure", result.Preview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("FF", result.StatusCode, result.Preview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "FF", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "ff", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + cookies, err := resolveCookies(ctx, req.Logger, req.TrackerConfig, req.Runtime.DBPath, dryRun) + if err != nil { + return uploadState{}, nil, err + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(assets) + fields := map[string]string{ + "MAX_FILE_SIZE": "10000000", + "type": resolveTypeID(req.Meta), + "tags": "", + "descr": description, + } + category := strings.ToUpper(strings.TrimSpace(categoryOf(req.Meta))) + switch { + case req.Meta.Anime: + fields["anime_type"] = resolveAnimeType(req.Meta) + fields["anime_source"] = resolveAnimeSource(req.Meta) + fields["anime_container"] = "mkv" + fields["anime_v_res"] = req.Meta.Release.Resolution + fields["anime_v_dar"] = "16_9" + fields["anime_v_codec"] = resolveAnimeVCodec(req.Meta) + case category == "MOVIE": + fields["movie_type"] = resolveMovieType(req.Meta) + fields["movie_source"] = resolveMovieSource(req.Meta) + fields["movie_imdb"] = resolveIMDbURL(req.Meta) + fields["pack"] = "0" + default: + fields["tv_type"] = resolveTVType(req.Meta) + fields["tv_source"] = resolveTVSource(req.Meta) + fields["tv_imdb"] = resolveIMDbURL(req.Meta) + fields["pack"] = "0" + if req.Meta.TVPack { + fields["pack"] = "1" + } + } + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: resolveName(req.Meta), + fields: fields, + extraFiles: resolveExtraFiles(ctx, req.Meta), + } + return state, cookies, nil +} + +func resolveCookies(ctx context.Context, logger api.Logger, cfg config.TrackerConfig, dbPath string, dryRun bool) ([]*http.Cookie, error) { + loaded, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "FF", "www.funfile.org") + if err != nil { + if logger != nil { + logger.Debugf("trackers: LoadTrackerHTTPCookies failed for FF/www.funfile.org, dbPath=%s: %v", dbPath, err) + } + } else if len(loaded) > 0 { + return loaded, nil + } + if dryRun { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: FF cookies not found") + } + // #nosec G124 -- Dry-run sentinel is an outbound tracker jar cookie, not a browser-set cookie. + return []*http.Cookie{{ + Name: "dryrun", + Value: "1", + Domain: ".funfile.org", + Path: "/", + }}, nil + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: FF cookie invalid/missing and username/password not configured") + } + data := url.Values{} + data.Set("returnto", "/index.php") + data.Set("username", strings.TrimSpace(cfg.Username)) + data.Set("password", strings.TrimSpace(cfg.Password)) + data.Set("login", "Login") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: FF login request build: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "upbrr") + client := httpclient.CloneWithTimeout( + &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, + httpclient.DefaultTimeout, + ) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("trackers: FF login request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("trackers: FF login failed status=%d", resp.StatusCode) + } + return resp.Cookies(), nil +} + +func buildDescription(assets trackers.DescriptionAssets) string { + if strings.TrimSpace(assets.Description) != "" { + return finalizeDescription(strings.TrimSpace(assets.Description)) + } + return "" +} + +func resolveExtraFiles(ctx context.Context, meta api.UploadSubject) []commonhttp.FileField { + files := make([]commonhttp.FileField, 0, 2) + if ctx == nil { + return files + } + if poster := resolvePoster(meta); poster != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, poster, nil) + if err == nil { + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) + if err == nil { + defer resp.Body.Close() + if body, err := io.ReadAll(resp.Body); err == nil && len(body) > 0 { + files = append(files, commonhttp.FileField{ + FieldName: "poster", + FileName: "poster.jpg", + Content: body, + }) + } + } + } + } + dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) + if payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo"); err == nil { + files = append(files, commonhttp.FileField{ + FieldName: "nfo", + FileName: filepath.Base(path), + Content: payload, + }) + } + return files +} + +func resolveTypeID(meta api.UploadSubject) string { + if meta.Anime { + return "44" + } + if strings.EqualFold(categoryOf(meta), "TV") { + return "7" + } + return "19" +} + +func resolveMovieType(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.Source), "DVD") { + return "DVDR" + } + if strings.Contains(strings.ToLower(meta.VideoCodec), "hevc") || strings.Contains(strings.ToLower(meta.VideoEncode), "265") { + return "x265" + } + return "x264" +} + +func resolveTVType(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.Source), "DVD") { + return "DVDR" + } + if strings.Contains(strings.ToLower(meta.Source), "web") { + if isSD(meta) { + return "Web-SD" + } + return "Web-HD" + } + if strings.Contains(strings.ToLower(meta.VideoCodec), "hevc") || strings.Contains(strings.ToLower(meta.VideoEncode), "265") { + if isSD(meta) { + return "x265-SD" + } + return "x265-HD" + } + if isSD(meta) { + return "x264-SD" + } + return "x264-HD" +} + +func resolveAnimeType(meta api.UploadSubject) string { + if meta.SeasonInt == 0 { + return "TVSpecial" + } + if strings.EqualFold(categoryOf(meta), "MOVIE") { + return "Movie" + } + return "TVSeries" +} + +func resolveMovieSource(meta api.UploadSubject) string { + switch strings.ToLower(strings.TrimSpace(meta.Source)) { + case "dvd": + return "DVD" + case "blu-ray", "bluray": + return "BluRay" + case "hdtv": + return "HDTV" + case "webrip", "webdl", "web": + return "WebRIP" + default: + return "BluRay" + } +} + +func resolveTVSource(meta api.UploadSubject) string { + switch strings.ToLower(strings.TrimSpace(meta.Source)) { + case "dvd": + return "DVD" + case "blu-ray", "bluray": + return "BluRay" + case "hdtv": + return "HDTV" + case "webrip", "webdl", "web": + return "WebRIP" + default: + return "HDTV" + } +} + +func resolveAnimeSource(meta api.UploadSubject) string { + switch strings.ToLower(strings.TrimSpace(meta.Source)) { + case "dvd": + return "DVD" + case "blu-ray", "bluray": + return "BluRay" + default: + return "Anime Series" + } +} + +func resolveAnimeVCodec(meta api.UploadSubject) string { + if strings.Contains(strings.ToLower(meta.VideoCodec), "vc-1") { + return "VC1" + } + if strings.Contains(strings.ToLower(meta.VideoEncode), "h.264") { + return "h264" + } + return "x264" +} + +func resolveName(meta api.UploadSubject) string { + if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { + return strings.TrimSpace(meta.SceneName) + } + return strings.ReplaceAll(metautil.FirstNonEmptyTrimmed(meta.ReleaseNameClean, meta.ReleaseName, meta.Filename), " ", ".") +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + } + return "" +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func isSD(meta api.UploadSubject) bool { + return strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "480p") || strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "576p") +} diff --git a/internal/trackers/impl/standalone/fl/auth.go b/internal/trackers/impl/standalone/fl/auth.go new file mode 100644 index 00000000..5963a59c --- /dev/null +++ b/internal/trackers/impl/standalone/fl/auth.go @@ -0,0 +1,192 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package fl + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" +) + +const flAuthResponseMaxBytes = 1 << 20 + +var flAuthValidatorPattern = regexp.MustCompile(`name="validator"\s+value="([^"]+)"`) + +// validateAuthCookies checks bounded FL home-page evidence. Explicit login +// evidence and a missing logout marker are confirmed-invalid; transport, read, +// and other HTTP failures remain transient. +func validateAuthCookies(ctx context.Context, baseURL string, values []*http.Cookie) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/index.php", nil) + if err != nil { + return fmt.Errorf("trackers: FL session validation request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + for _, cookie := range values { + if cookie != nil && strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + req.AddCookie(cookie) + } + } + resp, err := flAuthHTTPClient(nil).Do(req) + if err != nil { + return &trackerauth.ValidationError{ + TrackerID: "FL", + Transient: true, + Reason: "remote validation unavailable", + Err: fmt.Errorf("trackers: FL session validation request: %w", err), + } + } + defer resp.Body.Close() + body, err := readFLAuthBody(resp) + if err != nil { + return &trackerauth.ValidationError{ + TrackerID: "FL", + Transient: true, + Reason: "remote validation unavailable", + Err: err, + } + } + lower := strings.ToLower(string(body)) + location := strings.ToLower(resp.Header.Get("Location")) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || strings.Contains(location, "login") || + strings.Contains(lower, "login.php") || + strings.Contains(lower, "name=\"username\"") || + strings.Contains(lower, "name=\"password\"") { + return &trackerauth.ValidationError{ + TrackerID: "FL", + ConfirmedInvalid: true, + Reason: "stored session expired", + Err: fmt.Errorf("trackers: FL session validation failed status=%d", resp.StatusCode), + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &trackerauth.ValidationError{ + TrackerID: "FL", + Transient: true, + Reason: "remote validation failed", + Err: fmt.Errorf("trackers: FL session validation failed status=%d", resp.StatusCode), + } + } + if !strings.Contains(lower, "logout") { + return &trackerauth.ValidationError{ + TrackerID: "FL", + ConfirmedInvalid: true, + Reason: "stored session expired", + Err: errors.New("trackers: FL logout marker not found"), + } + } + return nil +} + +// loginAuthSession fetches FL's validator token, submits configured +// credentials, and persists only non-empty cookies that pass remote validation. +func loginAuthSession(ctx context.Context, cfg config.TrackerConfig, dbPath string, baseURL string) error { + jar, err := cookiejar.New(nil) + if err != nil { + return fmt.Errorf("trackers: FL create login cookie jar: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/login.php", nil) + if err != nil { + return fmt.Errorf("trackers: FL login page request build: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("trackers: FL login page request: %w", err) + } + body, readErr := readFLAuthBody(resp) + closeErr := resp.Body.Close() + if readErr != nil { + return fmt.Errorf("trackers: FL read login page response: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("trackers: FL close login page response: %w", closeErr) + } + match := flAuthValidatorPattern.FindStringSubmatch(string(body)) + if len(match) < 2 { + return errors.New("trackers: FL validator token not found") + } + data := url.Values{ + "validator": {match[1]}, + "username": {strings.TrimSpace(cfg.Username)}, + "password": {strings.TrimSpace(cfg.Password)}, + "unlock": {"1"}, + } + loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/takelogin.php", strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("trackers: FL login request build: %w", err) + } + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginResp, err := client.Do(loginReq) + if err != nil { + return fmt.Errorf("trackers: FL login request: %w", err) + } + defer loginResp.Body.Close() + if loginResp.StatusCode < 200 || loginResp.StatusCode >= 400 { + return &trackerauth.ValidationError{ + TrackerID: "FL", + ConfirmedInvalid: true, + Reason: "login failed", + Err: fmt.Errorf("trackers: FL login failed status=%d", loginResp.StatusCode), + } + } + base, err := url.Parse(baseURL + "/") + if err != nil { + return fmt.Errorf("trackers: FL parse base URL: %w", err) + } + loginCookies := jar.Cookies(base) + if len(usableFLAuthCookies(loginCookies)) == 0 { + return errors.New("trackers: FL login returned no usable cookies") + } + if err := validateAuthCookies(ctx, baseURL, loginCookies); err != nil { + return fmt.Errorf("trackers: FL validate login cookies: %w", err) + } + if err := cookies.SaveTrackerHTTPCookies(ctx, dbPath, "FL", usableFLAuthCookies(loginCookies)); err != nil { + return fmt.Errorf("trackers: FL persist login cookies: %w", err) + } + return nil +} + +func flAuthHTTPClient(jar http.CookieJar) *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +func readFLAuthBody(resp *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, flAuthResponseMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("trackers: FL read auth response: %w", err) + } + if len(body) > flAuthResponseMaxBytes { + return nil, errors.New("trackers: FL auth response exceeds limit") + } + return body, nil +} + +func usableFLAuthCookies(values []*http.Cookie) []*http.Cookie { + usable := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + if cookie != nil && strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + usable = append(usable, cookie) + } + } + return usable +} + +func flHasLoginCredentials(cfg config.TrackerConfig) bool { + return strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "" +} diff --git a/internal/trackers/impl/standalone/fl/bbcode.go b/internal/trackers/impl/standalone/fl/bbcode.go new file mode 100644 index 00000000..029d5a57 --- /dev/null +++ b/internal/trackers/impl/standalone/fl/bbcode.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package fl + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.RemoveSpoiler(value) + value = bbcode.ConvertCodeToQuote(value) + value = bbcode.ConvertComparisonToCentered(value, 900) + value = bbcode.RemoveImageResize(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/fl/dupe.go b/internal/trackers/impl/standalone/fl/dupe.go new file mode 100644 index 00000000..50d22c3b --- /dev/null +++ b/internal/trackers/impl/standalone/fl/dupe.go @@ -0,0 +1,182 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package fl + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + baseURL := flBaseURL(s.cfg) + parsedBase, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "FL search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "FL", parsedBase.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid FL cookies", nil) + } + params := url.Values{"cat": {strconv.Itoa(flCategoryID(meta))}} + if meta.Identity.IMDBID != 0 { + params.Set("search", fmt.Sprintf("tt%07d", meta.Identity.IMDBID)) + params.Set("searchin", "3") + } else { + query := metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName) + if query == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing FL search query", nil) + } + params.Set("search", query) + params.Set("searchin", "0") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/browse.php", nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "FL search failed", err) + } + req.URL.RawQuery = params.Encode() + req.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(req, trackerCookies) + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "FL search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "FL search failed", nil) + } + root, err := xhtml.Parse(resp.Body) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "FL search failed", err) + } + entries := make([]api.DupeEntry, 0) + flWalk(root, func(node *xhtml.Node) { + if node.Type != xhtml.ElementNode || node.Data != "a" { + return + } + href := flAttr(node, "href") + if !strings.HasPrefix(href, "details.php?id=") || strings.Contains(href, "&") { + return + } + name := metautil.FirstNonEmptyTrimmed(flAttr(node, "title"), flText(node)) + if name == "" { + return + } + link := flAbsoluteURL(baseURL, href) + id := "" + if parsed, parseErr := url.Parse(link); parseErr == nil { + id = strings.TrimSpace(parsed.Query().Get("id")) + } + entries = append(entries, api.DupeEntry{ + Name: name, + ID: id, + Link: link, + }) + }) + return dupe.Resolved(entries, nil) +} + +func flBaseURL(_ config.Config) string { + return "https://filelist.io" +} +func flCategoryID(meta api.DuplicateSubject) int { + if meta.Anime { + return 24 + } + category := strings.ToUpper(strings.TrimSpace(string(meta.Identity.Category))) + resolution := strings.TrimSpace(meta.Release.Resolution) + switch { + case strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD"): + return 2 + case category == "TV": + if resolution == "2160p" { + return 27 + } + if resolution == "480p" || resolution == "576p" { + return 23 + } + return 21 + default: + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + if resolution == "2160p" { + return 26 + } + return 20 + } + if resolution == "2160p" { + return 6 + } + if resolution == "480p" || resolution == "576p" { + return 1 + } + return 4 + } +} +func flWalk(node *xhtml.Node, visit func(*xhtml.Node)) { + if node == nil { + return + } + visit(node) + for child := node.FirstChild; child != nil; child = child.NextSibling { + flWalk(child, visit) + } +} +func flAttr(node *xhtml.Node, key string) string { + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, key) { + return strings.TrimSpace(attr.Val) + } + } + return "" +} +func flText(node *xhtml.Node) string { + if node == nil { + return "" + } + if node.Type == xhtml.TextNode { + return node.Data + } + var builder strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + builder.WriteString(flText(child)) + } + return strings.TrimSpace(builder.String()) +} +func flAbsoluteURL(baseURL, value string) string { + parsed, err := url.Parse(value) + if err != nil { + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(value, "/") + } + base, err := url.Parse(baseURL) + if err != nil { + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(value, "/") + } + return base.ResolveReference(parsed).String() +} diff --git a/internal/trackers/impl/standalone/fl/profile.go b/internal/trackers/impl/standalone/fl/profile.go new file mode 100644 index 00000000..6aacae8a --- /dev/null +++ b/internal/trackers/impl/standalone/fl/profile.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package fl + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/internal/cookieauth" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns FL identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "FL", + BaseURL: baseURL, + DescriptionGroup: "fl", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: "FL", + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"reactor.filelist", "reactor.thefl.org"}, + }, + AuthCapability: &api.TrackerAuthCapability{ + AuthKind: "cookies_login", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + }, + AuthResolver: cookieauth.CookieLoginResolver(cookieauth.CookieLoginSpec{ + TrackerID: "FL", + BaseURL: baseURL, + CookieDomain: ".filelist.io", + Validate: validateAuthCookies, + HasCredential: flHasLoginCredentials, + Login: loginAuthSession, + }), + } +} + +// New returns a fresh FL definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + return trackers.DescriptionResult{Group: "fl", Description: buildDescription(assets)}, nil +} diff --git a/internal/trackers/impl/standalone/fl/upload.go b/internal/trackers/impl/standalone/fl/upload.go new file mode 100644 index 00000000..3de88ddc --- /dev/null +++ b/internal/trackers/impl/standalone/fl/upload.go @@ -0,0 +1,466 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package fl + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://filelist.io" + loginPageURL = baseURL + "/login.php" + loginURL = baseURL + "/takelogin.php" + uploadURL = baseURL + "/takeupload.php" + downloadURL = baseURL + "/download.php?id=" +) + +var ( + validatorPattern = regexp.MustCompile(`name="validator"\s+value="([^"]+)"`) + successPattern = regexp.MustCompile(`details\.php\?id=(\d+)&uploaded=`) + newHTTPClient = httpclient.New +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + questionnaire *api.TrackerQuestionnaire + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: FL %s", state.blockedReason) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ + FieldName: "file", + FileName: resolveTorrentFileName(req.Meta, state.releaseName) + ".torrent", + Path: state.torrentPath, + }}) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: FL create cookie jar: %w", err) + } + base, _ := url.Parse(baseURL) + jar.SetCookies(base, cookies) + client := httpclient.CloneWithTimeout(&http.Client{Jar: jar}, httpclient.DefaultTimeout) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, client, body, contentType) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + client *http.Client, + body []byte, + contentType string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: FL request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + result, err := commonhttp.ExecuteUpload(client, httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "FL", + SuccessStatus: func(status int) bool { return status >= 200 && status < 400 }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: FL execute upload: %w", err) + } + match := successPattern.FindStringSubmatch(result.FinalURL) + if len(match) >= 2 { + id := match[1] + artifactPath, err := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "FL") + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + if err := downloadPersonalizedTorrent(ctx, client, id, artifactPath); err != nil { + return api.UploadSummary{}, err + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "FL", + TorrentID: id, + TorrentURL: baseURL + "/details.php?id=" + id, + DownloadURL: downloadURL + id, + TorrentPath: artifactPath, + }}, + }, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "FL", "upload_failure", result.Preview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("FL", result.StatusCode, result.FinalURL, result.Preview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "FL", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "fl", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + cookies, err := resolveCookies(ctx, req.Logger, req.TrackerConfig, req.Runtime.DBPath, dryRun) + if err != nil { + return uploadState{}, nil, err + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(assets) + name := resolveName(req.Meta, standalone.QuestionnaireAnswers(req.Meta, "FL")) + fields := map[string]string{ + "name": name, + "type": strconv.Itoa(resolveCategoryID(req.Meta)), + "descr": description, + "nfo": resolveMedia(req.Meta), + } + if req.Meta.Identity.IMDBID > 0 { + fields["imdbid"] = strconv.Itoa(req.Meta.Identity.IMDBID) + fields["description"] = resolveGenres(req.Meta) + } + if strings.TrimSpace(req.TrackerConfig.UploaderName) != "" && !req.TrackerConfig.Anon { + fields["epenis"] = strings.TrimSpace(req.TrackerConfig.UploaderName) + } + if hasRomanianAudio(req.Meta) { + fields["materialro"] = "on" + } + if strings.EqualFold(strings.TrimSpace(req.Meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(req.Meta.Type), "REMUX") || req.Meta.TVPack { + fields["freeleech"] = "on" + } + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: name, + fields: fields, + questionnaire: buildQuestionnaire(req.Meta, name), + } + if strings.TrimSpace(name) == "" { + state.blockedReason = "missing release name" + } + return state, cookies, nil +} + +// resolveCookies returns usable stored FL cookies or performs credential login. +// Login-page read errors are reported before token parsing, and successful +// credential login requires durable cookie persistence. +func resolveCookies(ctx context.Context, logger api.Logger, cfg config.TrackerConfig, dbPath string, dryRun bool) ([]*http.Cookie, error) { + loaded, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "FL", ".filelist.io") + if err != nil { + if logger != nil { + logger.Debugf("trackers: LoadTrackerHTTPCookies failed for FL/.filelist.io, dbPath=%s: %v", dbPath, err) + } + } else if valid := validFLCookies(loaded); len(valid) > 0 { + return valid, nil + } else if logger != nil { + logger.Debugf("trackers: FL loaded cookies were missing/expired, falling back to credential login, dbPath=%s", dbPath) + } + if dryRun { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: FL cookies not found") + } + // #nosec G124 -- Dry-run sentinel is an outbound tracker jar cookie, not a browser-set cookie. + return []*http.Cookie{{ + Name: "dryrun", + Value: "1", + Domain: ".filelist.io", + Path: "/", + }}, nil + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: FL cookie invalid/missing and username/password not configured") + } + if err := ensureLoginCookieStorageAvailable(dbPath); err != nil { + return nil, err + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("trackers: FL create login cookie jar: %w", err) + } + client := newHTTPClient(httpclient.DefaultTimeout) + client.Jar = jar + req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginPageURL, nil) + if err != nil { + return nil, fmt.Errorf("trackers: FL login page request build: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("trackers: FL login page request: %w", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("trackers: FL read login page response: %w", err) + } + match := validatorPattern.FindStringSubmatch(string(body)) + if len(match) < 2 { + return nil, errors.New("trackers: FL validator token not found") + } + data := url.Values{} + data.Set("validator", match[1]) + data.Set("username", strings.TrimSpace(cfg.Username)) + data.Set("password", strings.TrimSpace(cfg.Password)) + data.Set("unlock", "1") + loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: FL login request build: %w", err) + } + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginResp, err := client.Do(loginReq) + if err != nil { + return nil, fmt.Errorf("trackers: FL login request: %w", err) + } + defer loginResp.Body.Close() + if loginResp.StatusCode < 200 || loginResp.StatusCode >= 400 { + return nil, fmt.Errorf("trackers: FL login failed status=%d", loginResp.StatusCode) + } + base, _ := url.Parse(baseURL) + loginCookies := client.Jar.Cookies(base) + if err := persistLoginCookies(ctx, dbPath, loginCookies); err != nil { + return nil, err + } + return loginCookies, nil +} + +// persistLoginCookies saves FL login cookies and returns any persistence error +// so callers do not report login success without durable cookie storage. +func persistLoginCookies(ctx context.Context, dbPath string, values []*http.Cookie) error { + valid := validFLCookies(values) + if len(valid) == 0 { + return errors.New("trackers: FL login returned no usable cookies") + } + if err := cookies.SaveTrackerHTTPCookies(ctx, dbPath, "FL", valid); err != nil { + return fmt.Errorf("trackers: FL persist login cookies: %w", err) + } + return nil +} + +func ensureLoginCookieStorageAvailable(dbPath string) error { + if _, err := authmaterial.LoadFromDBPath(dbPath); err != nil { + if errors.Is(err, authmaterial.ErrUnavailable) { + return fmt.Errorf("trackers: FL encrypted cookie storage unavailable before credential login: %w", cookies.ErrAuthHelperUnavailable) + } + return fmt.Errorf("trackers: FL check encrypted cookie storage before credential login: %w", err) + } + return nil +} + +func validFLCookies(values []*http.Cookie) []*http.Cookie { + now := time.Now() + valid := make([]*http.Cookie, 0, len(values)) + for _, cookie := range values { + if cookie == nil { + continue + } + if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { + continue + } + if !cookie.Expires.IsZero() && cookie.Expires.Before(now) { + continue + } + valid = append(valid, cookie) + } + return valid +} + +func downloadPersonalizedTorrent(ctx context.Context, client *http.Client, id string, outputPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL+id, nil) + if err != nil { + return fmt.Errorf("trackers: FL torrent download request build: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("trackers: FL torrent download request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("trackers: FL read torrent response: %w", err) + } + if err := os.WriteFile(outputPath, body, 0o600); err != nil { + return fmt.Errorf("trackers: FL write torrent output: %w", err) + } + return nil +} + +func buildDescription(assets trackers.DescriptionAssets) string { + return finalizeDescription(strings.TrimSpace(assets.Description)) +} + +func buildQuestionnaire(meta api.UploadSubject, computedName string) *api.TrackerQuestionnaire { + answers := standalone.QuestionnaireAnswers(meta, "FL") + return &api.TrackerQuestionnaire{Tracker: "FL", Fields: []api.TrackerQuestionnaireField{{ + Key: "name", + Label: "FileList Name", + Kind: "text", + Value: metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["name"]), computedName), + Required: true, + }}} +} + +func resolveName(meta api.UploadSubject, answers map[string]string) string { + if answers != nil && strings.TrimSpace(answers["name"]) != "" { + return strings.TrimSpace(answers["name"]) + } + name := strings.TrimSpace(meta.ReleaseName) + name = strings.ReplaceAll(name, " DV ", " DoVi ") + name = strings.ReplaceAll(name, "BluRay REMUX", "Remux") + name = strings.ReplaceAll(name, "BluRay Remux", "Remux") + name = strings.ReplaceAll(name, "PQ10", "HDR") + name = strings.ReplaceAll(name, "HDR10+", "HDR") + name = strings.ReplaceAll(name, "DD+", "DDP") + name = strings.Join(strings.Fields(name), ".") + return strings.Trim(name, ".") +} + +func resolveTorrentFileName(meta api.UploadSubject, releaseName string) string { + if meta.Anime && strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")), "SubsPlease") { + return releaseName + } + return strings.TrimSuffix(strings.TrimSpace(meta.Filename), filepath.Ext(strings.TrimSpace(meta.Filename))) +} + +func resolveCategoryID(meta api.UploadSubject) int { + if meta.Anime { + return 24 + } + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + resolution := strings.TrimSpace(meta.Release.Resolution) + switch { + case strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD"): + if hasRomanianSub(meta) { + return 3 + } + return 2 + case category == "TV": + if resolution == "2160p" { + return 27 + } + if isSD(meta) { + return 23 + } + return 21 + default: + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + if resolution == "2160p" { + return 26 + } + return 20 + } + if resolution == "2160p" { + return 6 + } + if isSD(meta) { + return 1 + } + if hasRomanianSub(meta) { + return 19 + } + return 4 + } +} + +func hasRomanianAudio(meta api.UploadSubject) bool { + for _, lang := range meta.AudioLanguages { + lower := strings.ToLower(strings.TrimSpace(lang)) + if lower == "romanian" || lower == "ro" { + return true + } + } + return false +} + +func hasRomanianSub(meta api.UploadSubject) bool { + for _, lang := range meta.SubtitleLanguages { + lower := strings.ToLower(strings.TrimSpace(lang)) + if lower == "romanian" || lower == "ro" { + return true + } + } + return false +} + +func resolveMedia(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return strings.TrimSpace(meta.Disc.Summary) + } + return metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), strings.TrimSpace(meta.DVDVOBMediaInfoText)) +} + +func resolveGenres(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + } + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + } + return strings.TrimSpace(meta.Release.Genre) +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func isSD(meta api.UploadSubject) bool { + resolution := strings.TrimSpace(meta.Release.Resolution) + return resolution == "480p" || resolution == "576p" +} diff --git a/internal/trackers/impl/fl/upload_test.go b/internal/trackers/impl/standalone/fl/upload_test.go similarity index 100% rename from internal/trackers/impl/fl/upload_test.go rename to internal/trackers/impl/standalone/fl/upload_test.go diff --git a/internal/trackers/impl/standalone/gpw/banned_groups.go b/internal/trackers/impl/standalone/gpw/banned_groups.go new file mode 100644 index 00000000..daf6559b --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/banned_groups.go @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +// bannedGroups returns GPW's static release-group blacklist. +func bannedGroups() []string { + return []string{ + "ALT", + "aXXo", + "BATWEB", + "BlackTV", + "BitsTV", + "BMDRu", + "BRrip", + "CM8", + "CrEwSaDe", + "CTFOH", + "CTRLHD", + "DDHDTV", + "DNL", + "DreamHD", + "ENTHD", + "FaNGDiNG0", + "FGT", + "HD2DVD", + "HDTime", + "HDT", + "Huawei", + "GPTHD", + "ION10", + "iPlanet", + "KiNGDOM", + "Leffe", + "Mp4Ba", + "mHD", + "MiniHD", + "mSD", + "MOMOWEB", + "nHD", + "nikt0", + "NSBC", + "nSD", + "NhaNc3", + "NukeHD", + "OFT", + "PRODJi", + "RARBG", + "RDN", + "SANTi", + "SeeHD", + "SeeWEB", + "SM737", + "SonyHD", + "STUTTERSHIT", + "TAGWEB", + "ViSION", + "VXT", + "WAF", + "x0r", + "Xiaomi", + "YIFY", + } +} diff --git a/internal/trackers/impl/standalone/gpw/bbcode.go b/internal/trackers/impl/standalone/gpw/bbcode.go new file mode 100644 index 00000000..feda3be0 --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/bbcode.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.RemoveSup(value) + value = bbcode.RemoveSub(value) + value = bbcode.ConvertToAlign(value) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/gpw/definition.go b/internal/trackers/impl/standalone/gpw/definition.go new file mode 100644 index 00000000..2e237aa5 --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/definition.go @@ -0,0 +1,25 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "gpw", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/gpw/description_assets_test.go b/internal/trackers/impl/standalone/gpw/description_assets_test.go new file mode 100644 index 00000000..a48a69ef --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/description_assets_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, + Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "GPW", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + ScreenshotHeader: "Screenshots token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "GPW", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/gpw/dupe.go b/internal/trackers/impl/standalone/gpw/dupe.go new file mode 100644 index 00000000..85621250 --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/dupe.go @@ -0,0 +1,109 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: "https://greatposterwall.com/api.php", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := gpwAPIKey(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb id for GPW dupe search", nil) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.endpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "GPW search failed", err) + } + req.URL.RawQuery = url.Values{ + "api_key": {apiKey}, + "action": {"torrent"}, + "imdbID": {"tt" + strconv.Itoa(meta.Identity.IMDBID)}, + }.Encode() + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "GPW search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "GPW search failed", nil) + } + var payload struct { + Status int `json:"status"` + Response []map[string]any `json:"response"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return dupe.Failed(dupe.FailureResponseParse, "GPW search failed", err) + } + if payload.Status != http.StatusOK { + return dupe.Failed(dupe.FailureResponseStatus, "GPW api rejected search", nil) + } + entries := make([]api.DupeEntry, 0, len(payload.Response)) + for _, item := range payload.Response { + parts := []string{ + gpwString(item["Name"]), + gpwString(item["Year"]), + gpwString(item["Resolution"]), + gpwString(item["Source"]), + gpwString(item["Processing"]), + gpwString(item["RemasterTitle"]), + gpwString(item["Codec"]), + } + entries = append(entries, api.DupeEntry{Name: strings.Join(strings.Fields(strings.Join(parts, " ")), " ")}) + } + return dupe.Resolved(entries, nil) +} + +func gpwAPIKey(cfg config.Config) string { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "GPW") { + return strings.TrimSpace(entry.APIKey) + } + } + return "" +} + +func gpwString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case json.Number: + return typed.String() + default: + return "" + } +} diff --git a/internal/trackers/impl/standalone/gpw/profile.go b/internal/trackers/impl/standalone/gpw/profile.go new file mode 100644 index 00000000..7f06597c --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/profile.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns GPW identity, preparation, dupe, bans, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "GPW", + BaseURL: baseURL, + DescriptionGroup: "gpw", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + BannedGroups: bannedGroups(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + ImageHostPolicy: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"kshare", "pixhost", "pterclub", "ilikeshots", "imgbox"}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://tracker.greatposterwall.com"}, + }, + } +} + +// New returns a fresh GPW definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/gpw/upload.go b/internal/trackers/impl/standalone/gpw/upload.go new file mode 100644 index 00000000..1743b46c --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/upload.go @@ -0,0 +1,659 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://greatposterwall.com" + torrentURL = baseURL + "/torrents.php?torrentid=" + sourceFlag = "GreatPosterWall" +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + groupID string + questionnaire *api.TrackerQuestionnaire + blockedReason string +} + +type apiResponse struct { + Status any `json:"status"` + Response any `json:"response"` + Error string `json:"error"` + Message string `json:"message"` +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: GPW %s", state.blockedReason) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ + FieldName: "file_input", + FileName: "GPW.placeholder.torrent", + Path: state.torrentPath, + }}) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + uploadEndpoint := baseURL + "/api.php?api_key=" + req.TrackerConfig.APIKey + "&action=upload" + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "GPW") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType, uploadEndpoint, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, + uploadEndpoint string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + uploadEndpoint, + bytes.NewReader(body), + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: GPW upload request build: %s", commonhttp.RedactErrorDetail(err.Error())) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: GPW upload request: %w", err) + } + defer resp.Body.Close() + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode >= 200 && resp.StatusCode < 300, + commonhttp.DefaultResponsePreviewBytes, + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: GPW read upload response: %w", err) + } + var decoded apiResponse + if err := json.Unmarshal(responseBody, &decoded); err != nil { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return api.UploadSummary{}, commonhttp.UploadHTTPError("GPW", resp.StatusCode, responsePreview) + } + return api.UploadSummary{}, fmt.Errorf("trackers: GPW decode response: %w", err) + } + id := extractTorrentID(decoded.Response) + status := strings.ToLower(strings.TrimSpace(fmt.Sprint(decoded.Status))) + if (status == "success" || status == "ok" || status == "200") && id != "" { + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "GPW", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}}, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "GPW", "upload_failure", responsePreview, ".json") + return api.UploadSummary{}, fmt.Errorf( + "trackers: GPW %s", + metautil.FirstNonEmptyTrimmed( + commonhttp.ExtractHTTPErrorDetail(responsePreview), + commonhttp.RedactErrorDetail(decoded.Error), + commonhttp.RedactErrorDetail(decoded.Message), + "upload failed", + ), + ) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + message := "dry-run payload generated" + if state.groupID == "" { + message += " for new group" + } else { + message += " for existing group" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "GPW", + ReadyMessage: message, + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "gpw", + Description: state.description, + Endpoint: baseURL + "/api.php?action=upload", + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: GPW missing api_key") + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + groupID, _ := lookupGroupID(ctx, req.TrackerConfig.APIKey, req.Meta) + answers := standalone.QuestionnaireAnswers(req.Meta, "GPW") + fields := buildFields(req, req.TrackerConfig, description, groupID, answers) + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), + fields: fields, + groupID: groupID, + questionnaire: buildQuestionnaire(req.Meta, groupID, answers), + } + if reason := validateFields(groupID, fields); reason != "" { + state.blockedReason = reason + } + return state, nil +} + +func lookupGroupID(ctx context.Context, apiKey string, meta api.UploadSubject) (string, error) { + if meta.Identity.IMDBID == 0 { + return "", nil + } + url := fmt.Sprintf("%s/api.php?api_key=%s&action=torrent&req=group&imdbID=tt%07d", baseURL, apiKey, meta.Identity.IMDBID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("trackers: GPW torrent URL lookup request build: %s", commonhttp.RedactErrorDetail(err.Error())) + } + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(req) + if err != nil { + return "", fmt.Errorf("trackers: GPW torrent URL lookup request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var decoded apiResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return "", nil + } + if responseMap, ok := decoded.Response.(map[string]any); ok { + if value, ok := responseMap["ID"]; ok { + return strings.TrimSpace(fmt.Sprint(value)), nil + } + } + return "", nil +} + +func buildFields( + req trackers.PreparationInput, + trackerCfg config.TrackerConfig, + description string, + groupID string, + answers map[string]string, +) map[string]string { + meta := req.Meta + fields := map[string]string{ + "codec_other": "", + "codec": resolveCodec(meta), + "container_other": "", + "container": resolveContainer(meta), + "mediainfo[]": trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta), + "movie_edition_information": onOff(strings.TrimSpace(meta.Edition) != ""), + "processing_other": "", + "processing": resolveProcessing(meta), + "release_desc": description, + "remaster_custom_title": "", + "remaster_title": strings.TrimSpace(meta.Edition), + "remaster_year": "", + "resolution_height": "", + "resolution_width": "", + "resolution": resolveResolution(meta), + "source_other": "", + "source": resolveSource(meta), + "submit": "true", + "subtitle_type": resolveSubtitleType(meta), + "subtitles[]": strings.Join(resolveSubtitles(meta), ","), + } + if groupID != "" { + fields["groupid"] = groupID + } else { + fields["data_source"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["data_source"]), "imdb") + fields["identifier"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["identifier"]), resolveIdentifier(meta)) + fields["desc"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["desc"]), resolveOverview(meta)) + fields["image"] = strings.TrimSpace(answers["poster_url"]) + fields["maindesc"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["main_desc"]), resolveOverview(meta)) + fields["name"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["title"]), meta.Release.Title) + fields["releasetype"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["release_type"]), resolveMovieType(meta)) + fields["subname"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["subname"]), meta.Release.Title) + fields["tags"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["tags"]), resolveTags(meta)) + fields["year"] = strconv.Itoa(resolveYear(meta)) + fields["artists[]"] = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["director_name"]), resolveDirectorName(meta)) + fields["importance[]"] = "1" + fields["artist_ids[]"] = strings.TrimSpace(answers["director_imdb"]) + fields["artist_subs[]"] = strings.TrimSpace(answers["director_chinese"]) + fields["characters[]"] = "" + fields["main_artist_number"] = "1" + } + maps.Copy(fields, resolveMediaFlags(meta)) + if meta.Scene { + fields["scene"] = "on" + } + if meta.PersonalRelease { + if isDiscUpload(meta) { + fields["buy"] = "on" + } else { + fields["diy"] = "on" + } + } + if trackerCfg.Exclusive { + fields["jinzhuan"] = "on" + } + return fields +} + +func isDiscUpload(meta api.UploadSubject) bool { + return strings.TrimSpace(meta.DiscType) != "" || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") +} + +func buildQuestionnaire(meta api.UploadSubject, groupID string, answers map[string]string) *api.TrackerQuestionnaire { + if groupID != "" { + return nil + } + fields := []api.TrackerQuestionnaireField{ + { + Key: "poster_url", + Label: "Poster URL", + Kind: "text", + Value: metautil.FirstNonEmptyTrimmed(answers["poster_url"], resolvePoster(meta)), + Required: true, + }, + { + Key: "director_imdb", + Label: "Director IMDb ID", + Kind: "text", + Value: answers["director_imdb"], + Placeholder: "nm0000138", + Required: true, + }, + { + Key: "director_name", + Label: "Director Name", + Kind: "text", + Value: metautil.FirstNonEmptyTrimmed(answers["director_name"], resolveDirectorName(meta)), + Required: true, + }, + { + Key: "director_chinese", + Label: "Director Chinese", + Kind: "text", + Value: answers["director_chinese"], + }, + { + Key: "tags", + Label: "Tags", + Kind: "text", + Value: metautil.FirstNonEmptyTrimmed(answers["tags"], resolveTags(meta)), + Required: true, + }, + } + return &api.TrackerQuestionnaire{Tracker: "GPW", Fields: fields} +} + +func validateFields(groupID string, fields map[string]string) string { + if groupID == "" { + for _, key := range []string{"image", "artists[]", "artist_ids[]", "tags"} { + if strings.TrimSpace(fields[key]) == "" { + return "missing required new-group data" + } + } + } + return "" +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + parts := make([]string, 0, 3) + // custom header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // logo + logoURL, logoSize := descriptionunit3d.ResolveLogo(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig()) + if logoURL != "" { + if strings.HasSuffix(logoURL, ".svg") { + logoURL = strings.ReplaceAll(logoURL, ".svg", ".png") + } + parts = append(parts, fmt.Sprintf("[center][img=%d]%s[/img][/center]", logoSize, logoURL)) + } + + // description + if base := strings.TrimSpace(assets.Description); base != "" { + parts = append(parts, base) + } + + // menu + if len(assets.MenuImages) > 0 { + // header + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + // images + if shots := screenshotBlock(assets.MenuImages); shots != "" { + parts = append(parts, shots) + } + } + // Screenshot Header + if header := strings.TrimSpace(req.Runtime.Description.ScreenshotHeader); header != "" { + parts = append(parts, header) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // screenshots + if shots := screenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + + // custom user signature + if signature := strings.TrimSpace(req.Runtime.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + // upbrr signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[align=right][url=%s][size=1]%s[/size][/url][/align]", link, text)) + + // finalize description + finalDescription := finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "GPW", req.Runtime.DBPath, finalDescription, req.Logger) + } + + return finalDescription +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := make([]string, 0, len(images)) + for _, image := range images { + if strings.TrimSpace(image.RawURL) == "" { + continue + } + parts = append(parts, "[img]"+strings.TrimSpace(image.RawURL)+"[/img]") + } + if len(parts) == 0 { + return "" + } + return "[center]" + strings.Join(parts, " ") + "[/center]" +} + +func extractTorrentID(value any) string { + switch typed := value.(type) { + case map[string]any: + if torrentID, ok := typed["torrent_id"]; ok { + return strings.TrimSpace(fmt.Sprint(torrentID)) + } + case []any: + if len(typed) > 0 { + if first, ok := typed[0].(map[string]any); ok { + if torrentID, ok := first["torrent_id"]; ok { + return strings.TrimSpace(fmt.Sprint(torrentID)) + } + } + } + } + return "" +} + +func resolveCodec(meta api.UploadSubject) string { + codec := strings.ToLower(strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.VideoEncode, meta.VideoCodec))) + switch { + case strings.Contains(codec, "hevc"), strings.Contains(codec, "265"): + return "HEVC" + case strings.Contains(codec, "avc"), strings.Contains(codec, "264"): + return "AVC" + case strings.Contains(codec, "vc-1"): + return "VC-1" + default: + return "Other" + } +} + +func resolveContainer(meta api.UploadSubject) string { + container := strings.ToLower(strings.TrimSpace(meta.Container)) + switch container { + case "mkv", "mp4", "avi", "vob", "m2ts": + return strings.ToUpper(container) + default: + return "Other" + } +} + +func resolveProcessing(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "ENCODE": + return "Encode" + case "REMUX": + return "Remux" + case "DIY": + return "DIY" + default: + return "Untouched" + } +} + +func resolveResolution(meta api.UploadSubject) string { + resolution := strings.ToLower(strings.TrimSpace(meta.Release.Resolution)) + switch resolution { + case "480p", "576p", "720p", "1080i", "1080p", "2160p": + return resolution + default: + return "Other" + } +} + +func resolveSource(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.Type)) { + case "DISC": + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "Blu-ray" + } + return "DVD" + case "WEBDL", "WEBRIP": + return "WEB" + case "REMUX", "ENCODE": + return "Blu-ray" + case "HDTV": + return "HDTV" + default: + return "Other" + } +} + +func resolveSubtitleType(meta api.UploadSubject) string { + if len(meta.SubtitleLanguages) > 0 { + return "1" + } + return "3" +} + +func resolveSubtitles(meta api.UploadSubject) []string { + out := make([]string, 0, len(meta.SubtitleLanguages)) + for _, lang := range meta.SubtitleLanguages { + switch strings.ToLower(strings.TrimSpace(lang)) { + case "english", "en": + out = append(out, "English") + case "chinese", "zh": + out = append(out, "Chinese") + case "portuguese", "pt": + out = append(out, "Portuguese") + } + } + return out +} + +func resolveIdentifier(meta api.UploadSubject) string { + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("tt%07d", meta.Identity.IMDBID) + } + if meta.Identity.TMDBID > 0 { + return strconv.Itoa(meta.Identity.TMDBID) + } + return "" +} + +func resolveOverview(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + } + return "" +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + } + return "" +} + +func resolveMovieType(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.RuntimeMinutes > 0 && meta.ProviderMetadata.IMDB.RuntimeMinutes < 45 { + return "2" + } + return "1" +} + +func resolveTags(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(strings.ToLower(strings.ReplaceAll(meta.ProviderMetadata.TMDB.Genres, ", ", ","))) + } + return strings.TrimSpace(strings.ToLower(meta.Release.Genre)) +} + +func resolveDirectorName(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Directors) > 0 { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Directors[0]) + } + if meta.ProviderMetadata.IMDB != nil && len(meta.ProviderMetadata.IMDB.Directors) > 0 { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Directors[0].Name) + } + return "" +} + +func resolveYear(meta api.UploadSubject) int { + if meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0 { + return meta.ProviderMetadata.TMDB.Year + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0 { + return meta.ProviderMetadata.IMDB.Year + } + return meta.Release.Year +} + +func resolveMediaFlags(meta api.UploadSubject) map[string]string { + flags := map[string]string{} + audio := strings.ToLower(strings.TrimSpace(meta.Audio)) + hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) + if strings.Contains(audio, "atmos") { + flags["dolby_atmos"] = "on" + } + if strings.Contains(audio, "dts:x") { + flags["dts_x"] = "on" + } + if meta.Channels == "5.1" { + flags["audio_51"] = "on" + } + if meta.Channels == "7.1" { + flags["audio_71"] = "on" + } + if meta.BitDepth == "10" && hdr == "" { + flags["10_bit"] = "on" + } + if strings.Contains(hdr, "DV") { + flags["dolby_vision"] = "on" + } + if strings.Contains(hdr, "HDR10+") { + flags["hdr10plus"] = "on" + } else if strings.Contains(hdr, "HDR") { + flags["hdr10"] = "on" + } + return flags +} + +func onOff(value bool) string { + if value { + return "on" + } + return "" +} diff --git a/internal/trackers/impl/standalone/gpw/upload_test.go b/internal/trackers/impl/standalone/gpw/upload_test.go new file mode 100644 index 00000000..c7dc204b --- /dev/null +++ b/internal/trackers/impl/standalone/gpw/upload_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package gpw + +import ( + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildFieldsPersonalReleaseAndExclusiveFlags(t *testing.T) { + t.Parallel() + + base := api.UploadSubject{PersonalRelease: true} + + nonDisc := buildFields(trackers.PreparationInput{Meta: base}, config.TrackerConfig{Exclusive: true}, "description", "group", nil) + if nonDisc["diy"] != "on" { + t.Fatalf("expected non-disc personal release to use diy flag, got %#v", nonDisc) + } + if _, ok := nonDisc["self_rip"]; ok { + t.Fatalf("did not expect legacy self_rip flag, got %#v", nonDisc) + } + if nonDisc["jinzhuan"] != "on" { + t.Fatalf("expected exclusive flag to set jinzhuan, got %#v", nonDisc) + } + + discMeta := base + discMeta.DiscType = "BDMV" + disc := buildFields(trackers.PreparationInput{Meta: discMeta}, config.TrackerConfig{}, "description", "group", nil) + if disc["buy"] != "on" { + t.Fatalf("expected disc personal release to use buy flag, got %#v", disc) + } + if _, ok := disc["diy"]; ok { + t.Fatalf("did not expect diy for disc personal release, got %#v", disc) + } +} diff --git a/internal/trackers/impl/standalone/hdb/auth.go b/internal/trackers/impl/standalone/hdb/auth.go new file mode 100644 index 00000000..5b0da41c --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/auth.go @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +const hdbAuthResponseMaxBytes = 1 << 20 + +// resolveAuthSessionAt validates required HDB credentials and stored cookies +// without attempting login. Missing auth material requires operator action; +// explicit login evidence is confirmed-invalid, while ambiguous remote or read +// failures remain transient. +func resolveAuthSessionAt( + ctx context.Context, + cfg config.TrackerConfig, + dbPath string, + _ api.TrackerAuthLoginRequest, + baseURL string, + httpClient *http.Client, +) error { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Passkey) == "" { + return &trackers.AuthResolutionError{ + Reason: "username/passkey missing", + AuthRequired: true, + Err: errors.New("trackers: HDB missing username/passkey"), + } + } + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "HDB", "hdbits.org") + if err != nil || len(values) == 0 { + if err == nil { + err = cookies.ErrTrackerCookiesNotFound + } + return &trackers.AuthResolutionError{ + Reason: "cookies missing", + AuthRequired: true, + Err: fmt.Errorf("trackers: HDB cookies unavailable: %w", err), + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/upload/upload", nil) + if err != nil { + return fmt.Errorf("trackers: HDB session validation request build: %w", err) + } + req.Header.Set("User-Agent", "upbrr") + for _, cookie := range values { + if cookie != nil && strings.TrimSpace(cookie.Name) != "" && strings.TrimSpace(cookie.Value) != "" { + req.AddCookie(cookie) + } + } + client := httpClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + } + resp, err := client.Do(req) + if err != nil { + return &trackers.AuthResolutionError{ + Reason: "remote validation unavailable", + Transient: true, + Err: fmt.Errorf("trackers: HDB session validation request: %w", err), + } + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, hdbAuthResponseMaxBytes+1)) + if err != nil { + return &trackers.AuthResolutionError{ + Reason: "remote validation unavailable", + Transient: true, + Err: fmt.Errorf("trackers: HDB read validation response: %w", err), + } + } + if len(body) > hdbAuthResponseMaxBytes { + return &trackers.AuthResolutionError{ + Reason: "remote validation unavailable", + Transient: true, + Err: errors.New("trackers: HDB validation response exceeds limit"), + } + } + bodyText := strings.ToLower(string(body)) + location := strings.ToLower(resp.Header.Get("Location")) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || strings.Contains(location, "login") || + strings.Contains(bodyText, "name=\"username\"") || + strings.Contains(bodyText, "login.php") { + return &trackers.AuthResolutionError{ + Reason: "stored session expired", + ConfirmedInvalid: true, + Err: fmt.Errorf("trackers: HDB session validation failed status=%d", resp.StatusCode), + } + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &trackers.AuthResolutionError{ + Reason: "remote validation failed", + Transient: true, + Err: fmt.Errorf("trackers: HDB session validation failed status=%d", resp.StatusCode), + } + } + if !strings.Contains(bodyText, "upload") && !strings.Contains(bodyText, "torrent") { + return &trackers.AuthResolutionError{ + Reason: "stored session expired", + ConfirmedInvalid: true, + Err: errors.New("trackers: HDB upload marker not found"), + } + } + return nil +} diff --git a/internal/trackers/impl/standalone/hdb/bbcode.go b/internal/trackers/impl/standalone/hdb/bbcode.go new file mode 100644 index 00000000..b4582cc5 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/bbcode.go @@ -0,0 +1,108 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" + imagehost "github.com/autobrr/upbrr/internal/imagehosting/host" +) + +var ( + hdbComparisonSectionPattern = regexp.MustCompile(`(?i)\[center\]\s*\[b\].*?(Comparison|vs).*?\[/b\][\s\S]*?\[/center\]`) + hdbComparisonLinePattern = regexp.MustCompile(`(?i)(.*comparison.*)\n`) + hdbEmptyURLPattern = regexp.MustCompile(`(?i)\[url=https?://(img\.|t\.)?hdbits\.org[^\]]*\]\[/url\]`) + hdbEmptyAnyURLPattern = regexp.MustCompile(`(?is)\[url\s*=\s*]\s*\[/url\]`) + hdbURLTagPattern = regexp.MustCompile(`(?i)(\[url[=]]https?://(img\.|t\.)?hdbits\.org[^\]]+\])(.*?)(\[/url\])?`) + hdbImgPattern = regexp.MustCompile(`(?i)\[img\][\s\S]*?(img\.|t\.)?hdbits\.org[\s\S]*?\[/img\]`) + hdbStandalonePattern = regexp.MustCompile(`(?i)https?://(img\.|t\.)?hdbits\.org/[^\s\[\]]+`) + hdbURLTagAnyPattern = regexp.MustCompile(`(?i)\[url[^\]]*hdbits\.org[^\]]*\](.*?)\[/url\]`) + hdbSelfClosingPattern = regexp.MustCompile(`(?i)\[url=https?://[^\]]*hdbits\.org[^\]]*\]\[/url\]`) + hdbCenterEmptyPattern = regexp.MustCompile(`(?i)\[center\]\s*\[/center\]`) + hdbBBCodeURLImgPattern = regexp.MustCompile(`(?i)\[url=(https?://[^\]]+)\]\[img\](https?://[^\]]+)\[/img\]\[/url\]`) +) + +// CleanDescription removes HDB-hosted images, links, and comparison sections +// while extracting non-HDB linked images into the report. Empty or tag-only +// cleaned text is omitted without discarding extracted images. +func CleanDescription(description string) bbcode.Report { + desc := bbcode.NormalizeNewlines(description) + imagelist := make([]bbcode.Image, 0) + + sections := hdbComparisonSectionPattern.FindAllString(desc, -1) + for _, section := range sections { + if strings.Contains(strings.ToLower(section), "hdbits.org") { + desc = strings.ReplaceAll(desc, section, "") + } + } + + lines := hdbComparisonLinePattern.FindAllStringIndex(desc, -1) + for _, match := range lines { + start := match[0] + end := min(start+500, len(desc)) + segment := desc[start:end] + parts := strings.SplitN(segment, "\n", 4) + if len(parts) > 3 { + parts = parts[:3] + } + nextLines := strings.Join(parts, "\n") + if strings.Contains(strings.ToLower(nextLines), "hdbits.org") { + desc = strings.ReplaceAll(desc, nextLines, "") + } + } + + desc = hdbEmptyURLPattern.ReplaceAllString(desc, "") + urlTags := hdbURLTagPattern.FindAllString(desc, -1) + for _, urlTag := range urlTags { + desc = strings.ReplaceAll(desc, urlTag, "") + } + + imgTags := hdbImgPattern.FindAllString(desc, -1) + for _, imgTag := range imgTags { + desc = strings.ReplaceAll(desc, imgTag, "") + } + + standalone := hdbStandalonePattern.FindAllString(desc, -1) + for _, url := range standalone { + desc = strings.ReplaceAll(desc, url, "") + } + + desc = hdbURLTagAnyPattern.ReplaceAllString(desc, "") + desc = hdbSelfClosingPattern.ReplaceAllString(desc, "") + desc = hdbEmptyAnyURLPattern.ReplaceAllString(desc, "") + desc = hdbComparisonSectionPattern.ReplaceAllString(desc, "") + desc = hdbCenterEmptyPattern.ReplaceAllString(desc, "") + desc = bbcode.RemoveExtraLines(desc) + + desc = hdbBBCodeURLImgPattern.ReplaceAllStringFunc(desc, func(value string) string { + parts := hdbBBCodeURLImgPattern.FindStringSubmatch(value) + if len(parts) < 3 { + return value + } + webURL := parts[1] + imgURL := parts[2] + lower := strings.ToLower(webURL + imgURL) + if strings.Contains(lower, "hdbits.org") { + return "" + } + rawURL := imgURL + rawURL = bbcode.NormalizeImageRawURL(rawURL) + host := imagehost.ExtractHost(imgURL) + imagelist = append(imagelist, bbcode.Image{ + ImgURL: imgURL, + RawURL: rawURL, + WebURL: webURL, + Host: host, + }) + return "" + }) + + desc = strings.TrimSpace(desc) + if desc == "" || bbcode.IsOnlyTags(desc) { + return bbcode.Report{Images: imagelist} + } + return bbcode.Report{Description: desc, Images: imagelist} +} diff --git a/internal/trackers/impl/standalone/hdb/data.go b/internal/trackers/impl/standalone/hdb/data.go new file mode 100644 index 00000000..81d941ca --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/data.go @@ -0,0 +1,147 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/config" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +type dataLookup struct { + cfg config.Config + http *http.Client + endpoint string +} + +// NewDataLookup returns an HDB lookup bound to the definition's base URL, cfg, +// and httpClient. +func (d *Definition) NewDataLookup(cfg config.Config, httpClient *http.Client, _ api.Logger) trackers.DataLookup { + return &dataLookup{ + cfg: cfg, + http: httpClient, + endpoint: strings.TrimRight(d.baseURL, "/") + "/api/torrents", + } +} + +// Lookup resolves the first HDB torrent by tracker ID or folder/file search. +// Missing credentials or filters, non-success responses, API rejection, and +// empty data produce an empty result without an error. OnlyID and KeepImages +// independently control description and image projection. +func (l *dataLookup) Lookup(ctx context.Context, req trackers.DataLookupRequest) (trackers.DataLookupResult, error) { + username, passkey := hdbCredentials(l.cfg) + if username == "" || passkey == "" { + return trackers.DataLookupResult{}, nil + } + payload := map[string]any{"username": username, "passkey": passkey} + if id := strings.TrimSpace(req.TrackerID); id != "" { + payload["id"] = id + } else { + payload["limit"] = 100 + hasFilter := false + if strings.TrimSpace(req.Meta.DiscType) != "" || len(req.Meta.FileList) != 1 { + payload["search"], hasFilter = pathutil.Base(req.Meta.SourcePath), true + } else if name := strings.TrimSpace(req.SearchName); name != "" { + payload["file_in_torrent"], hasFilter = name, true + } + if !hasFilter { + return trackers.DataLookupResult{}, nil + } + } + first, err := l.requestFirst(ctx, payload) + if err != nil || len(first) == 0 { + return trackers.DataLookupResult{}, err + } + result := trackers.DataLookupResult{ + TrackerID: hdbString(first["id"]), + IMDBID: hdbNestedInt(first, "imdb", "id"), + TVDBID: hdbNestedInt(first, "tvdb", "id"), + InfoHash: hdbString(first["hash"]), + } + if result.TrackerID == "" { + result.TrackerID = strings.TrimSpace(req.TrackerID) + } + if req.OnlyID && !req.KeepImages { + return result, nil + } + report := CleanDescription(hdbString(first["descr"])) + if !req.OnlyID { + result.Description = strings.TrimSpace(report.Description) + } + if req.KeepImages { + result.Images = report.Images + } + return result, nil +} + +func (l *dataLookup) requestFirst(ctx context.Context, payload map[string]any) (map[string]any, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("trackerdata: hdb encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("trackerdata: hdb request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := l.http.Do(req) + if err != nil { + return nil, fmt.Errorf("trackerdata: hdb request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil + } + result := map[string]any{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("trackerdata: hdb decode: %w", err) + } + if hdbInt(result["status"]) != 0 { + return nil, nil + } + items, _ := result["data"].([]any) + if len(items) == 0 { + return nil, nil + } + item, _ := items[0].(map[string]any) + return item, nil +} + +func hdbCredentials(cfg config.Config) (string, string) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "HDB") { + return strings.TrimSpace(entry.Username), strings.TrimSpace(entry.Passkey) + } + } + return "", "" +} + +func hdbString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func hdbInt(value any) int { + var result int + _, _ = fmt.Sscan(hdbString(value), &result) + return result +} + +func hdbNestedInt(value map[string]any, root, key string) int { + node, _ := value[root].(map[string]any) + return hdbInt(node[key]) +} diff --git a/internal/trackers/impl/standalone/hdb/data_test.go b/internal/trackers/impl/standalone/hdb/data_test.go new file mode 100644 index 00000000..9a003540 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/data_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDataLookup(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"status": 0, "data": []any{map[string]any{ + "id": "321", + "hash": "deadbeef", + "imdb": map[string]any{"id": "998877"}, + "tvdb": map[string]any{"id": "5544"}, + "descr": "Text\n[url=https://imgbox.com/abc][img]https://thumbs2.imgbox.com/abc_t.png[/img][/url]", + }}}) + })) + defer server.Close() + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"HDB": {Username: "user", Passkey: "pass"}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected HDB data lookup") + } + lookup.endpoint = server.URL + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{TrackerID: "321", KeepImages: true}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if result.IMDBID != 998877 || result.TVDBID != 5544 || result.InfoHash != "deadbeef" || result.Description != "Text" || len(result.Images) != 1 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestDataLookupSkipsUnfilteredSearch(t *testing.T) { + t.Parallel() + requested := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requested = true + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + lookup, ok := New().NewDataLookup(config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{"HDB": {Username: "user", Passkey: "pass"}}}}, server.Client(), nil).(*dataLookup) + if !ok { + t.Fatal("expected HDB data lookup") + } + lookup.endpoint = server.URL + result, err := lookup.Lookup(context.Background(), trackers.DataLookupRequest{Meta: api.UploadSubject{SourcePath: `D:\TV\Example.Show.S04E01.2160p.WEB.h265-GRP.mkv`, FileList: []string{"Example.Show.S04E01.2160p.WEB.h265-GRP.mkv"}}, KeepImages: true}) + if err != nil || result.HasData() || requested { + t.Fatalf("err=%v data=%t requested=%t", err, result.HasData(), requested) + } +} diff --git a/internal/trackers/impl/standalone/hdb/definition.go b/internal/trackers/impl/standalone/hdb/definition.go new file mode 100644 index 00000000..23b25f68 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/definition.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/config" + + "github.com/autobrr/upbrr/internal/trackers" +) + +// DataLookupConfigured reports whether HDB metadata lookup credentials are available. +func (d *Definition) DataLookupConfigured(cfg config.Config) bool { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "HDB") { + return strings.TrimSpace(entry.Username) != "" && strings.TrimSpace(entry.Passkey) != "" + } + } + return false +} + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: HDB description assets: %w", err) + } + if req.Logger != nil { + req.Logger.Warnf("trackers: HDB description assets failed: %v", err) + } + assets = trackers.DescriptionAssets{} + } + + description := strings.TrimSpace(assets.Description) + if !assets.Final { + description, err = BuildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets.Description, assets.MenuImages, assets.Screenshots) + if err != nil { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: HDB description build: %w", err) + } + } + + if strings.TrimSpace(description) == "" && req.Logger != nil { + req.Logger.Infof("trackers: HDB preparation description empty") + } + + return trackers.DescriptionResult{ + Group: "hdb", + Description: description, + }, nil +} diff --git a/internal/trackers/impl/standalone/hdb/definition_test.go b/internal/trackers/impl/standalone/hdb/definition_test.go new file mode 100644 index 00000000..2ca16b52 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/definition_test.go @@ -0,0 +1,301 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func (d *Definition) submit(ctx context.Context, input trackers.PreparationInput) (api.UploadSummary, error) { + return uploadAt(ctx, input, d.baseURL, d.httpClient) +} + +func TestDefinitionBuildDescriptionUsesHDBGroup(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "HDB", + Meta: api.UploadSubject{}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Group != "hdb" { + t.Fatalf("expected hdb group, got %q", result.Group) + } +} + +func TestDefinitionBuildDescriptionUsesProvidedAssets(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "HDB", + Meta: api.UploadSubject{}, + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: "kept description", + Screenshots: []api.ScreenshotImage{{ + ImgURL: "https://t.hdbits.org/example.jpg", + WebURL: "https://img.hdbits.org/example", + RawURL: "https://img.hdbits.org/example", + }}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result.Description, "https://t.hdbits.org/example.jpg") { + t.Fatalf("expected provided screenshot in description, got %q", result.Description) + } +} + +func TestDefinitionBuildDescriptionUsesProvidedMenuImages(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "HDB", + Meta: api.UploadSubject{}, + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + MenuImages: []api.ScreenshotImage{{ + Purpose: api.ScreenshotPurposeMenu, + ImgURL: "https://t.hdbits.org/menu.jpg", + WebURL: "https://img.hdbits.org/menu", + }}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result.Description, "https://t.hdbits.org/menu.jpg") { + t.Fatalf("expected provided menu image in description, got %q", result.Description) + } +} + +func TestDefinitionUploadMissingCredentials(t *testing.T) { + d := New() + _, err := d.submit(context.Background(), trackers.PreparationInput{Tracker: "HDB", Logger: api.NopLogger{}}) + if err == nil { + t.Fatal("expected missing credentials error") + } +} + +func TestDefinitionUploadSuccess(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + dbPath := filepath.Join(tmp, "ua.db") + cookieDir := filepath.Join(tmp, "cookies") + if err := os.MkdirAll(cookieDir, 0o755); err != nil { + t.Fatalf("mkdir cookie dir: %v", err) + } + cookieText := "# Netscape HTTP Cookie File\n.hdbits.org\tTRUE\t/\tTRUE\t0\tuid\tcookievalue\n" + if err := os.WriteFile(filepath.Join(cookieDir, "HDB.txt"), []byte(cookieText), 0o600); err != nil { + t.Fatalf("write cookie file: %v", err) + } + + uploadSeen := false + downloadSeen := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/upload/upload": + uploadSeen = true + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "multipart/form-data") { + t.Errorf("expected multipart content-type, got %q", ct) + return + } + if err := r.ParseMultipartForm(5 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + if r.FormValue("name") == "" { + t.Error("expected upload name field") + return + } + if descr := r.FormValue("descr"); !strings.Contains(descr, "https://t.hdbits.org/rehosted.jpg") { + t.Errorf("expected provided rehosted screenshot in description, got %q", descr) + return + } + files := r.MultipartForm.File["file"] + if len(files) == 0 { + t.Error("expected torrent file in multipart form") + return + } + f, err := files[0].Open() + if err != nil { + t.Errorf("open uploaded file: %v", err) + return + } + _, _ = io.ReadAll(f) + _ = f.Close() + http.Redirect(w, r, "/details.php?id=123&uploaded=1", http.StatusFound) + case r.URL.Path == "/details.php": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + case strings.HasPrefix(r.URL.Path, "/download.php/"): + downloadSeen = true + if r.URL.Query().Get("id") != "123" { + t.Errorf("expected id=123, got %q", r.URL.Query().Get("id")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("reseed-torrent-bytes")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + d := &Definition{baseURL: server.URL, httpClient: server.Client()} + result, err := d.submit(context.Background(), trackers.PreparationInput{ + Tracker: "HDB", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Type: "WEBDL", + VideoCodec: "HEVC", + ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Passkey: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: "kept description", + Screenshots: []api.ScreenshotImage{{ + ImgURL: "https://t.hdbits.org/rehosted.jpg", + WebURL: "https://img.hdbits.org/rehosted", + RawURL: "https://img.hdbits.org/rehosted", + }}, + }, + }) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected uploaded=1, got %d", result.Uploaded) + } + if len(result.UploadedTorrents) != 1 { + t.Fatalf("expected one uploaded torrent artifact, got %d", len(result.UploadedTorrents)) + } + if result.UploadedTorrents[0].Tracker != "HDB" { + t.Fatalf("expected tracker HDB, got %q", result.UploadedTorrents[0].Tracker) + } + if result.UploadedTorrents[0].TorrentID != "123" { + t.Fatalf("expected torrent id 123, got %q", result.UploadedTorrents[0].TorrentID) + } + if !uploadSeen { + t.Fatal("expected upload endpoint to be called") + } + if !downloadSeen { + t.Fatal("expected download endpoint to be called") + } + artifactPath := result.UploadedTorrents[0].TorrentPath + if strings.TrimSpace(artifactPath) == "" { + t.Fatal("expected tracker-specific torrent path") + } + updated, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatalf("read updated torrent: %v", err) + } + if string(updated) != "reseed-torrent-bytes" { + t.Fatalf("expected downloaded torrent bytes, got %q", string(updated)) + } + original, err := os.ReadFile(torrentPath) + if err != nil { + t.Fatalf("read original torrent: %v", err) + } + if string(original) != "dummy" { + t.Fatalf("expected original torrent to remain unchanged, got %q", string(original)) + } +} + +func TestDefinitionBuildUploadDryRunUsesProvidedAssets(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "Movie.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + input := trackers.PreparationInput{ + Intent: trackers.PreparationIntentDryRun, + Tracker: "HDB", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Type: "WEBDL", + VideoCodec: "HEVC", + ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Passkey: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}), + Logger: api.NopLogger{}, + Assets: &trackers.DescriptionAssets{ + Description: "kept description", + Screenshots: []api.ScreenshotImage{{ + ImgURL: "https://t.hdbits.org/dryrun.jpg", + WebURL: "https://img.hdbits.org/dryrun", + RawURL: "https://img.hdbits.org/dryrun", + }}, + }, + } + plan, failure := New().Prepare(context.Background(), input) + if failure != nil { + t.Fatalf("unexpected dry-run error: %v", failure) + } + entry := plan.DryRun() + if !strings.Contains(entry.Description, "https://t.hdbits.org/dryrun.jpg") { + t.Fatalf("expected provided screenshot in dry-run description, got %q", entry.Description) + } +} + +func TestBuildUploadFieldsSkipsTVDBForMovie(t *testing.T) { + fields := buildUploadFields(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE", TVDBID: 765432}, + }, config.Config{}, 1, 5, 6, "description") + + if _, ok := fields["tvdb"]; ok { + t.Fatalf("did not expect tvdb for movie payload") + } + if _, ok := fields["tvdb_season"]; ok { + t.Fatalf("did not expect tvdb_season for movie payload") + } + if _, ok := fields["tvdb_episode"]; ok { + t.Fatalf("did not expect tvdb_episode for movie payload") + } +} + +func TestBuildUploadFieldsIncludesTVDBForTV(t *testing.T) { + fields := buildUploadFields(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 765432}, + SeasonInt: 2, + EpisodeInt: 3, + }, config.Config{}, 2, 5, 6, "description") + + if got := fields["tvdb"]; got != "765432" { + t.Fatalf("expected tvdb=765432, got %q", got) + } + if got := fields["tvdb_season"]; got != "2" { + t.Fatalf("expected tvdb_season=2, got %q", got) + } + if got := fields["tvdb_episode"]; got != "3" { + t.Fatalf("expected tvdb_episode=3, got %q", got) + } +} diff --git a/internal/trackers/impl/standalone/hdb/description_builder.go b/internal/trackers/impl/standalone/hdb/description_builder.go new file mode 100644 index 00000000..2323facd --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/description_builder.go @@ -0,0 +1,250 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "fmt" + "os" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + hdbImgSizedPattern = regexp.MustCompile(`(?i)\[img=\d+\]`) + hdbSizePattern = regexp.MustCompile(`(?i)\[/size\]|\[size=\d+\]`) + hdbSpoilerPattern = regexp.MustCompile(`(?i)\[spoiler([^\]]*)\]`) + hdbCompPattern = regexp.MustCompile(`(?i)\[comparison=([^\]]+)\]([\s\S]*?)\[/comparison\]`) + hdbImgURLPattern = regexp.MustCompile(`(?i)(https?://[^\s\]]+\.(?:png|jpg|jpeg|webp))`) + hdbURLImgPattern = regexp.MustCompile(`(?is)\[url=(https?://[^\]]+)\]\s*\[img(?:[^\]]*)?\](https?://[^\[]+?)\[/img\]\s*\[/url\]`) + hdbImgBlockPattern = regexp.MustCompile(`(?is)\[img(?:[^\]]*)?\](https?://[^\[]+?)\[/img\]`) + hdbUARegex = regexp.MustCompile( + `(?is)\[(?:right|align=right)\]\s*\[url=https://github\.com/(?:Audionut|autobrr)/upbrr\].*?\[/url\]\s*\[/(?:right|align)\]`, + ) + hdbEmptyWrapperTag = regexp.MustCompile(`(?is)\[(?:center|align=center)\]\s*\[/(?:center|align)\]`) +) + +// BuildDescription builds HDB-compatible BBCode from retained description +// text and resolved image assets. Disc-menu images render in their own section +// before normal screenshots; cancellation and disc-metadata read failures are +// returned to the caller. +func BuildDescription( + ctx context.Context, + meta api.UploadSubject, + appConfig config.Config, + keptDescription string, + menuImages []api.ScreenshotImage, + screenshots []api.ScreenshotImage, +) (string, error) { + select { + case <-ctx.Done(): + return "", fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + parts := make([]string, 0, 8) + + if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && strings.TrimSpace(meta.ServiceLongName) != "" && strings.TrimSpace(keptDescription) == "" { + parts = append(parts, fmt.Sprintf("[center][quote]This release is sourced from %s[/quote][/center]", strings.TrimSpace(meta.ServiceLongName))) + } + + discSection, err := buildDiscSection(meta, appConfig.MainSettings.DBPath) + if err != nil { + return "", err + } + if strings.TrimSpace(discSection) != "" { + parts = append(parts, discSection) + } + + if transformed := transformBaseDescription(keptDescription, len(menuImages) > 0 || len(screenshots) > 0); strings.TrimSpace(transformed) != "" { + parts = append(parts, transformed) + } + + if len(menuImages) > 0 { + if header := strings.TrimSpace(appConfig.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + if section := buildScreenshotSection(menuImages, 0); section != "" { + parts = append(parts, section) + } + } + + if section := buildScreenshotSection(screenshots, meta.Options.Screens); section != "" { + parts = append(parts, section) + } + + if signature := strings.TrimSpace(appConfig.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + return normalize(strings.Join(parts, "\n\n")), nil +} + +func buildDiscSection(meta api.UploadSubject, dbPath string) (string, error) { + if strings.TrimSpace(meta.DVDVOBMediaInfoText) != "" { + return "[quote=VOB MediaInfo]" + strings.TrimSpace(meta.DVDVOBMediaInfoText) + "[/quote]", nil + } + + if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "", nil + } + + if strings.TrimSpace(dbPath) == "" { + return "", nil + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("description: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("description: %w", err) + } + path := paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists)) + if strings.TrimSpace(path) == "" { + return "", nil + } + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("description: HDB read MediaInfo file: %w", err) + } + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return "", nil + } + return "[quote]" + trimmed + "[/quote]", nil +} + +func transformBaseDescription(value string, stripScreens bool) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + transformed := trimmed + transformed = hdbUARegex.ReplaceAllString(transformed, "") + if stripScreens { + transformed = hdbURLImgPattern.ReplaceAllString(transformed, "") + transformed = hdbImgBlockPattern.ReplaceAllString(transformed, "") + transformed = hdbEmptyWrapperTag.ReplaceAllString(transformed, "") + } + transformed = strings.ReplaceAll(transformed, "[code]", "[font=monospace]") + transformed = strings.ReplaceAll(transformed, "[/code]", "[/font]") + for _, tag := range []string{"user", "left", "right", "sup", "sub", "alert", "note", "hr", "ul", "ol"} { + transformed = strings.ReplaceAll(transformed, "["+tag+"]", "") + transformed = strings.ReplaceAll(transformed, "[/"+tag+"]", "") + } + transformed = strings.ReplaceAll(transformed, "[align=left]", "") + transformed = strings.ReplaceAll(transformed, "[align=right]", "") + transformed = strings.ReplaceAll(transformed, "[/align]", "") + transformed = strings.ReplaceAll(transformed, "[h1]", "[u][b]") + transformed = strings.ReplaceAll(transformed, "[/h1]", "[/b][/u]") + transformed = strings.ReplaceAll(transformed, "[h2]", "[u][b]") + transformed = strings.ReplaceAll(transformed, "[/h2]", "[/b][/u]") + transformed = strings.ReplaceAll(transformed, "[h3]", "[u][b]") + transformed = strings.ReplaceAll(transformed, "[/h3]", "[/b][/u]") + transformed = strings.ReplaceAll(transformed, "[*]", "* ") + transformed = hdbSpoilerPattern.ReplaceAllString(transformed, "[hide$1]") + transformed = strings.ReplaceAll(transformed, "[/spoiler]", "[/hide]") + transformed = convertComparisonToCentered(transformed, 1000) + transformed = hdbImgSizedPattern.ReplaceAllString(transformed, "[img]") + transformed = hdbSizePattern.ReplaceAllString(transformed, "") + return normalize(transformed) +} + +func convertComparisonToCentered(value string, maxWidth int) string { + return hdbCompPattern.ReplaceAllStringFunc(value, func(match string) string { + parts := hdbCompPattern.FindStringSubmatch(match) + if len(parts) < 3 { + return match + } + sourcePart := strings.TrimSpace(parts[1]) + imagePart := strings.TrimSpace(parts[2]) + sources := splitCSV(sourcePart) + if len(sources) == 0 { + return match + } + images := hdbImgURLPattern.FindAllString(imagePart, -1) + if len(images) == 0 { + return match + } + screensPerLine := len(sources) + if screensPerLine <= 0 { + return match + } + imgSize := min(maxWidth/screensPerLine, 350) + rows := make([]string, 0) + line := make([]string, 0, screensPerLine) + for _, img := range images { + u := strings.TrimSpace(img) + if u == "" { + continue + } + line = append(line, "[url="+u+"][img="+strconv.Itoa(imgSize)+"]"+u+"[/img][/url]") + if len(line) == screensPerLine { + rows = append(rows, strings.Join(line, "")) + line = line[:0] + } + } + return "[center]" + strings.Join(sources, " | ") + "\n" + strings.Join(rows, "\n") + "[/center]" + }) +} + +func splitCSV(value string) []string { + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func buildScreenshotSection(images []api.ScreenshotImage, maxCount int) string { + if len(images) == 0 { + return "" + } + limit := len(images) + if maxCount > 0 && maxCount < limit { + limit = maxCount + } + parts := make([]string, 0, limit+2) + parts = append(parts, "[center]") + for _, image := range images[:limit] { + imgURL := strings.TrimSpace(image.ImgURL) + if imgURL == "" { + imgURL = strings.TrimSpace(image.RawURL) + } + webURL := strings.TrimSpace(image.WebURL) + if imgURL == "" { + continue + } + if webURL != "" { + parts = append(parts, "[url="+webURL+"][img]"+imgURL+"[/img][/url]") + continue + } + parts = append(parts, "[img]"+imgURL+"[/img]") + } + parts = append(parts, "[/center]") + return strings.Join(parts, "") +} + +func normalize(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + re := regexp.MustCompile(`\n{3,}`) + return strings.TrimSpace(re.ReplaceAllString(trimmed, "\n\n")) +} diff --git a/internal/trackers/impl/standalone/hdb/description_builder_test.go b/internal/trackers/impl/standalone/hdb/description_builder_test.go new file mode 100644 index 00000000..379bf2c1 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/description_builder_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionAddsWebDLSource(t *testing.T) { + meta := api.UploadSubject{ + Type: "WEBDL", + ServiceLongName: "Netflix", + } + description, err := BuildDescription(context.Background(), meta, config.Config{}, "", nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(description, "This release is sourced from Netflix") { + t.Fatalf("expected web source quote, got %q", description) + } +} + +func TestBuildDescriptionTransformsBaseAndAddsScreens(t *testing.T) { + meta := api.UploadSubject{ + Options: api.UploadOptions{Screens: 1}, + } + base := "[code]x[/code]\n[spoiler=Comp][comparison=A, B]https://img/a.jpg https://img/b.jpg[/comparison][/spoiler]\n[size=3][img=300]https://img/x.jpg[/img][/size]" + screens := []api.ScreenshotImage{{ImgURL: "https://img/s1.jpg", WebURL: "https://web/s1"}, {ImgURL: "https://img/s2.jpg"}} + + description, err := BuildDescription(context.Background(), meta, config.Config{}, base, nil, screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(description, "[font=monospace]x[/font]") { + t.Fatalf("expected code tag conversion, got %q", description) + } + if !strings.Contains(description, "[hide=Comp]") { + t.Fatalf("expected spoiler-to-hide conversion, got %q", description) + } + if !strings.Contains(description, "[center][url=https://web/s1][img]https://img/s1.jpg[/img][/url][/center]") { + t.Fatalf("expected screenshot section with one image due to limit, got %q", description) + } + if strings.Contains(description, "https://img/s2.jpg") { + t.Fatalf("expected second screenshot omitted by limit, got %q", description) + } +} + +func TestBuildDescriptionAddsRehostedMenuImagesSeparately(t *testing.T) { + menuImages := []api.ScreenshotImage{{ + Purpose: api.ScreenshotPurposeMenu, + ImgURL: "https://t.hdbits.org/menu.jpg", + WebURL: "https://img.hdbits.org/menu", + }} + screens := []api.ScreenshotImage{{ + Purpose: api.ScreenshotPurposeFinal, + ImgURL: "https://t.hdbits.org/screen.jpg", + WebURL: "https://img.hdbits.org/screen", + }} + appConfig := config.Config{Description: config.DescriptionSettingsConfig{DiscMenuHeader: "[b]Disc menu[/b]"}} + + description, err := BuildDescription(context.Background(), api.UploadSubject{}, appConfig, "", menuImages, screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + menuPos := strings.Index(description, "https://t.hdbits.org/menu.jpg") + screenPos := strings.Index(description, "https://t.hdbits.org/screen.jpg") + if menuPos < 0 || screenPos < 0 { + t.Fatalf("expected menu and screenshot sections, got %q", description) + } + if !strings.Contains(description, "[b]Disc menu[/b]") { + t.Fatalf("expected disc menu header, got %q", description) + } + if menuPos >= screenPos { + t.Fatalf("expected menu before screenshots, got %q", description) + } +} + +func TestBuildDescriptionReplacesExistingScreenshotBlock(t *testing.T) { + meta := api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + } + base := `[align=center] +[url=https://pixhost.to/8ca234.png][img width=350]https://pixhost.to/8ca234.png[/img][/url] +[url=https://pixhost.to/4oh0bz.png][img width=350]https://pixhost.to/4oh0bz.png[/img][/url] +[/align] + +[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]` + screens := []api.ScreenshotImage{ + { + ImgURL: "https://t.hdbits.org/51q8jo2.jpg", + RawURL: "https://img.hdbits.org/51q8jo2.jpg", + WebURL: "https://img.hdbits.org/51q8jo2", + }, + { + ImgURL: "https://t.hdbits.org/w0S7ltI.jpg", + RawURL: "https://img.hdbits.org/w0S7ltI.jpg", + WebURL: "https://img.hdbits.org/w0S7ltI", + }, + } + + description, err := BuildDescription(context.Background(), meta, config.Config{}, base, nil, screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(description, "pixhost.to/8ca234.png") || strings.Contains(description, "pixhost.to/4oh0bz.png") { + t.Fatalf("expected old screenshot block to be removed, got %q", description) + } + if count := strings.Count(description, "[center][url=https://img.hdbits.org/"); count != 1 { + t.Fatalf("expected one rebuilt screenshot section, got %d in %q", count, description) + } + if !strings.Contains(description, "img.hdbits.org/51q8jo2") || !strings.Contains(description, "img.hdbits.org/w0S7ltI") { + t.Fatalf("expected new HDB screenshots, got %q", description) + } +} + +func TestBuildDescriptionPreservesExistingScreensWhenNoNewScreensProvided(t *testing.T) { + meta := api.UploadSubject{ + Options: api.UploadOptions{Screens: 4}, + } + base := `[align=center] +[url=https://pixhost.to/8ca234.png][img width=350]https://pixhost.to/8ca234.png[/img][/url] +[url=https://pixhost.to/4oh0bz.png][img width=350]https://pixhost.to/4oh0bz.png[/img][/url] +[/align] + +[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]` + + description, err := BuildDescription(context.Background(), meta, config.Config{}, base, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(description, "pixhost.to/8ca234.png") || !strings.Contains(description, "pixhost.to/4oh0bz.png") { + t.Fatalf("expected existing screenshot block preserved, got %q", description) + } + if strings.Contains(description, "upbrr") { + t.Fatalf("expected default signature removed, got %q", description) + } +} diff --git a/internal/trackers/impl/standalone/hdb/dupe.go b/internal/trackers/impl/standalone/hdb/dupe.go new file mode 100644 index 00000000..f6e06f56 --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/dupe.go @@ -0,0 +1,256 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + logger api.Logger + endpoint string +} + +// newDuplicateAdapterAt returns a duplicate-search adapter bound to one +// immutable dependency set and tracker base URL. +func newDuplicateAdapterAt(deps dupe.Dependencies, baseURL string) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + if logger == nil { + logger = api.NopLogger{} + } + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + logger: logger, + endpoint: strings.TrimRight(baseURL, "/") + "/api/torrents", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if s.http == nil { + return dupe.Failed(dupe.FailureInternal, "HDB handler misconfigured: no HTTP client", nil) + } + username, passkey := hdbCredentials(s.cfg) + if username == "" || passkey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing username/passkey for tracker", nil) + } + payload := map[string]any{ + "username": username, + "passkey": passkey, + "category": hdbDupeCategoryID(meta), + "codec": hdbDupeCodecID(meta), + "medium": hdbDupeMediumID(meta), + } + searchMethod := "id" + if meta.Identity.IMDBID != 0 { + payload["imdb"] = map[string]any{"id": fmt.Sprintf("%07d", meta.Identity.IMDBID)} + } else if isHDBDupeTVCategory(meta) && meta.Identity.TVDBID != 0 { + payload["tvdb"] = map[string]any{"id": meta.Identity.TVDBID} + } + if _, hasIMDB := payload["imdb"]; !hasIMDB { + if _, hasTVDB := payload["tvdb"]; !hasTVDB { + query := firstHDBText(meta.ReleaseName, meta.Filename, meta.Release.Title) + if query == "" { + s.logger.Warnf("dupechecking: HDB missing imdb/tvdb IDs and search text for %s", meta.SourcePath) + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb/tvdb id for HDB dupe search", nil) + } + payload["search"], searchMethod = query, "text_fallback" + s.logger.Debugf("dupechecking: HDB falling back to text search for %s", meta.SourcePath) + } + } + if logPayload, err := json.Marshal(redaction.RedactPrivateInfo(payload, nil)); err != nil { + s.logger.Debugf("dupechecking: HDB search payload_marshal_failed=%v source=%s", err, meta.SourcePath) + } else { + s.logger.Debugf("dupechecking: HDB search payload=%s source=%s", string(logPayload), meta.SourcePath) + } + raw, err := json.Marshal(payload) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "HDB request failed", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(raw)) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "HDB request failed", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + s.logger.Warnf("dupechecking: HDB request failed for %s: %v", meta.SourcePath, err) + return dupe.Failed(dupe.FailureRequest, "HDB request failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + s.logger.Warnf("dupechecking: HDB search failed for %s with status=%d", meta.SourcePath, resp.StatusCode) + return dupe.Failed(dupe.FailureResponseStatus, "HDB search failed", nil) + } + var body map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&body); err != nil || len(body) == 0 { + return dupe.Failed(dupe.FailureResponseParse, "HDB search failed", err) + } + if hdbInt(body["status"]) != 0 { + s.logger.Warnf("dupechecking: HDB API rejected search for %s", meta.SourcePath) + return dupe.Failed(dupe.FailureResponseStatus, "HDB api rejected search", nil) + } + items, _ := body["data"].([]any) + entries := make([]api.DupeEntry, 0, len(items)) + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + id, filename := hdbString(item["id"]), hdbString(item["filename"]) + entry := api.DupeEntry{ + Name: hdbString(item["name"]), + ID: id, + Link: "https://hdbits.org/details.php?id=" + id, + Download: "https://hdbits.org/download.php/" + url.QueryEscape(filename) + "?id=" + id + "&passkey=" + passkey, + FileCount: hdbInt(item["numfiles"]), + } + if size := hdbInt(item["size"]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, int64(size) + } + entries = append(entries, entry) + } + s.logger.Debugf("dupechecking: HDB returned %d entries for %s method=%s", len(entries), meta.SourcePath, searchMethod) + return dupe.Resolved(entries, nil) +} + +func firstHDBText(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func isHDBDupeTVCategory(meta api.DuplicateSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func hdbDupeCategoryID(meta api.DuplicateSubject) int { + category, _ := meta.Identity.RequireCategory() + switch category { + case api.CanonicalCategoryMovie: + return 1 + case api.CanonicalCategoryTV: + return 2 + case api.CanonicalCategoryUnknown: + } + genres, keywords := "", "" + if meta.ProviderMetadata.TMDB != nil { + genres = strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres)) + keywords = strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords)) + } + if strings.Contains(genres, "documentary") || strings.Contains(keywords, "documentary") { + return 3 + } + if meta.ProviderMetadata.IMDB != nil { + imdbType := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.IMDB.Type)) + imdbGenres := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres)) + if strings.Contains(imdbType, "concert") || (strings.Contains(imdbType, "video") && strings.Contains(imdbGenres, "music")) { + return 4 + } + } + return 0 +} + +func hdbDupeCodecID(meta api.DuplicateSubject) int { + codec := strings.ToUpper(strings.TrimSpace(meta.VideoCodec)) + if codec == "" { + codec = strings.ToUpper(strings.TrimSpace(meta.VideoEncode)) + } + switch codec { + case "AVC", "H.264": + return 1 + case "MPEG-2": + return 2 + case "VC-1": + return 3 + case "XVID": + return 4 + case "HEVC", "H.265": + return 5 + case "VP9": + return 6 + default: + return 0 + } +} + +func hdbDupeMediumID(meta api.DuplicateSubject) int { + discType := strings.ToUpper(strings.TrimSpace(meta.DiscType)) + contentType := resolveHDBDupeType(meta) + if discType == "BDMV" || discType == "HD DVD" { + return 1 + } + if contentType == "HDTV" { + if meta.HasEncodeSettings { + return 3 + } + return 4 + } + switch contentType { + case "ENCODE", "WEBRIP": + return 3 + case "REMUX": + return 5 + case "WEBDL": + return 6 + default: + return 0 + } +} + +func resolveHDBDupeType(meta api.DuplicateSubject) string { + typeValue := normalizeHDBType(meta.Type) + if typeValue == "" || isHDBCategoryType(typeValue) { + if meta.ReleaseNameOverrides.Type != nil { + typeValue = normalizeHDBType(*meta.ReleaseNameOverrides.Type) + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = normalizeHDBType(meta.Release.Type) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.DiscType) != "" { + typeValue = "DISC" + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = inferHDBTypeFromSource(meta.Source) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = inferHDBTypeFromPath(meta.SourcePath) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.VideoEncode) != "" { + typeValue = "ENCODE" + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.VideoCodec) != "" || strings.TrimSpace(meta.Release.Resolution) != "" || strings.TrimSpace(meta.Release.Ext) != "" { + typeValue = "ENCODE" + } + } + return typeValue +} diff --git a/internal/trackers/impl/standalone/hdb/dupe_integration_test.go b/internal/trackers/impl/standalone/hdb/dupe_integration_test.go new file mode 100644 index 00000000..adeaf0bd --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/dupe_integration_test.go @@ -0,0 +1,418 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +type hdbRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f hdbRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func hdbTestInt(value any) int64 { + switch typed := value.(type) { + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} + +func hdbTestString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func TestHDBHandlerSearchBuildsPayloadAndParsesResults(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + client := &http.Client{ + Transport: hdbRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("expected POST request, got %s", req.Method) + } + if req.URL.String() != "https://hdbits.org/api/torrents" { + t.Fatalf("unexpected endpoint %q", req.URL.String()) + } + + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request payload: %v", err) + } + if got := hdbTestString(payload["username"]); got != "user" { + t.Fatalf("unexpected username %q", got) + } + if got := hdbTestString(payload["passkey"]); got != "pk" { + t.Fatalf("unexpected passkey %q", got) + } + if got := hdbTestInt(payload["category"]); got != 1 { + t.Fatalf("unexpected category %d", got) + } + if got := hdbTestInt(payload["codec"]); got != 5 { + t.Fatalf("unexpected codec %d", got) + } + if got := hdbTestInt(payload["medium"]); got != 6 { + t.Fatalf("unexpected medium %d", got) + } + imdb, ok := payload["imdb"].(map[string]any) + if !ok || hdbTestString(imdb["id"]) != "1234567" { + t.Fatalf("unexpected imdb payload %#v", payload["imdb"]) + } + if _, hasTVDB := payload["tvdb"]; hasTVDB { + t.Fatalf("did not expect tvdb payload when imdb is present") + } + + body := `{"status":0,"data":[{"id":42,"name":"Movie.Title.2024.1080p.WEB-DL.DDP5.1.H.265-GRP","filename":"Movie Title (2024).torrent","size":1234567890,"numfiles":3}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "HDB", + config.Config{ + MainSettings: config.MainSettingsConfig{ + DBPath: filepath.Join(tmpDir, "ua.db"), + }, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "HDB": { + Username: "user", + Passkey: "pk", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + SourcePath: "C:/media/movie", + Identity: api.ExternalIdentity{ + IMDBID: 1234567, + TVDBID: 765432, + Category: "MOVIE", + }, + VideoCodec: "HEVC", + Type: "WEBDL", + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + + entry := entries[0] + if entry.Name != "Movie.Title.2024.1080p.WEB-DL.DDP5.1.H.265-GRP" { + t.Fatalf("expected entry name from 'name', got %q", entry.Name) + } + if entry.ID != "42" { + t.Fatalf("unexpected id %q", entry.ID) + } + if entry.Link != "https://hdbits.org/details.php?id=42" { + t.Fatalf("unexpected link %q", entry.Link) + } + if entry.Download != "https://hdbits.org/download.php/Movie+Title+%282024%29.torrent?id=42&passkey=pk" { + t.Fatalf("unexpected download %q", entry.Download) + } + if !entry.SizeKnown || entry.SizeBytes != 1234567890 { + t.Fatalf("unexpected size known=%t size=%d", entry.SizeKnown, entry.SizeBytes) + } + if entry.FileCount != 3 { + t.Fatalf("unexpected file count %d", entry.FileCount) + } +} + +func TestHDBHandlerSearchFallsBackToTextSearchWhenIDsMissing(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + client := &http.Client{ + Transport: hdbRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request payload: %v", err) + } + if _, hasIMDB := payload["imdb"]; hasIMDB { + t.Fatalf("did not expect imdb in payload") + } + if _, hasTVDB := payload["tvdb"]; hasTVDB { + t.Fatalf("did not expect tvdb in payload") + } + if got := hdbTestString(payload["search"]); got != "Some.Release.Name.2024.1080p.WEB-DL" { + t.Fatalf("unexpected fallback search %q", got) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":0,"data":[]}`)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "HDB", + config.Config{ + MainSettings: config.MainSettingsConfig{ + DBPath: filepath.Join(tmpDir, "ua.db"), + }, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "HDB": { + Username: "user", + Passkey: "pk", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + SourcePath: "C:/media/no-ids", + ReleaseName: "Some.Release.Name.2024.1080p.WEB-DL", + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +func TestHDBHandlerSearchUsesTVDBWhenIMDbMissing(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + client := &http.Client{ + Transport: hdbRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request payload: %v", err) + } + + if _, hasIMDB := payload["imdb"]; hasIMDB { + t.Fatalf("did not expect imdb in payload") + } + tvdb, ok := payload["tvdb"].(map[string]any) + if !ok || hdbTestInt(tvdb["id"]) != 765432 { + t.Fatalf("unexpected tvdb payload %#v", payload["tvdb"]) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":0,"data":[]}`)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "HDB", + config.Config{ + MainSettings: config.MainSettingsConfig{ + DBPath: filepath.Join(tmpDir, "ua.db"), + }, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "HDB": { + Username: "user", + Passkey: "pk", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + SourcePath: "C:/media/show", + Identity: api.ExternalIdentity{ + TVDBID: 765432, + Category: "TV", + }, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +func TestHDBHandlerSearchSkipsTVDBForMovie(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + client := &http.Client{ + Transport: hdbRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request payload: %v", err) + } + if _, hasTVDB := payload["tvdb"]; hasTVDB { + t.Fatalf("did not expect tvdb in movie payload") + } + if got := hdbTestString(payload["search"]); got != "Movie.Release.2024.1080p.WEB-DL" { + t.Fatalf("unexpected fallback search %q", got) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":0,"data":[]}`)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "HDB", + config.Config{ + MainSettings: config.MainSettingsConfig{ + DBPath: filepath.Join(tmpDir, "ua.db"), + }, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "HDB": { + Username: "user", + Passkey: "pk", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + SourcePath: "C:/media/movie", + ReleaseName: "Movie.Release.2024.1080p.WEB-DL", + Identity: api.ExternalIdentity{ + TVDBID: 765432, + Category: "MOVIE", + }, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +func TestHDBHandlerSearchIncludesZeroValuedFilters(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + client := &http.Client{ + Transport: hdbRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request payload: %v", err) + } + + category, hasCategory := payload["category"] + if !hasCategory { + t.Fatalf("expected category key to be present") + } + if got := hdbTestInt(category); got != 0 { + t.Fatalf("expected category 0, got %d", got) + } + + codec, hasCodec := payload["codec"] + if !hasCodec { + t.Fatalf("expected codec key to be present") + } + if got := hdbTestInt(codec); got != 0 { + t.Fatalf("expected codec 0, got %d", got) + } + + medium, hasMedium := payload["medium"] + if !hasMedium { + t.Fatalf("expected medium key to be present") + } + if got := hdbTestInt(medium); got != 0 { + t.Fatalf("expected medium 0, got %d", got) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":0,"data":[]}`)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "HDB", + config.Config{ + MainSettings: config.MainSettingsConfig{ + DBPath: filepath.Join(tmpDir, "ua.db"), + }, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "HDB": { + Username: "user", + Passkey: "pk", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + SourcePath: "C:/media/zero-filters", + ReleaseName: "Unknown.Release.2024", + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} diff --git a/internal/trackers/impl/standalone/hdb/dupe_test.go b/internal/trackers/impl/standalone/hdb/dupe_test.go new file mode 100644 index 00000000..2acf21aa --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/dupe_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestHDBMediumIDTypePrecedence(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + meta api.DuplicateSubject + want int + }{ + { + name: "metadata type takes precedence", + meta: api.DuplicateSubject{ + Type: "WEBDL", + ReleaseNameOverrides: api.ReleaseNameOverrides{Type: new("WEBRIP")}, + Release: api.ReleaseInfo{Type: "REMUX"}, + }, + want: 6, + }, + { + name: "falls back to override type", + meta: api.DuplicateSubject{ReleaseNameOverrides: api.ReleaseNameOverrides{Type: new("WEBRIP")}, Release: api.ReleaseInfo{Type: "REMUX"}}, + want: 3, + }, + { + name: "falls back to release type", + meta: api.DuplicateSubject{Release: api.ReleaseInfo{Type: "REMUX"}}, + want: 5, + }, + { + name: "disc type takes precedence", + meta: api.DuplicateSubject{ + DiscType: "BDMV", + Type: "WEBDL", + ReleaseNameOverrides: api.ReleaseNameOverrides{Type: new("WEBRIP")}, + Release: api.ReleaseInfo{Type: "REMUX"}, + }, + want: 1, + }, + { + name: "hdtv with encode settings maps to encode medium", + meta: api.DuplicateSubject{Type: "HDTV", HasEncodeSettings: true}, + want: 3, + }, + { + name: "category type infers encode medium from metadata", + meta: api.DuplicateSubject{ + Type: "movie", + Source: "BluRay", + VideoCodec: "AVC", + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + want: 3, + }, + { + name: "category type uses source hint for webdl medium", + meta: api.DuplicateSubject{Type: "movie", Source: "Web-DL"}, + want: 6, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := hdbDupeMediumID(tc.meta); got != tc.want { + t.Fatalf("hdbDupeMediumID() = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/hdb/profile.go b/internal/trackers/impl/standalone/hdb/profile.go new file mode 100644 index 00000000..5b52dbae --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/profile.go @@ -0,0 +1,80 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "context" + "net/http" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns HDB identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "HDB", + BaseURL: hdbBaseURL, + DescriptionGroup: "hdb", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: func(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, req, hdbBaseURL, nil) + }, + NewDuplicateAdapter: func(deps dupe.Dependencies) dupe.Adapter { return newDuplicateAdapterAt(deps, hdbBaseURL) }, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{ + { + Scope: trackers.MetadataScopeMovie, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDBIDOnly}, + Disposition: api.RuleDispositionStrict, + }, + { + Scope: trackers.MetadataScopeTV, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDBIDOnly, trackers.MetadataFieldTVDBIDOnly}, + Disposition: api.RuleDispositionStrict, + }, + }, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "HDBits"}, + ArtifactPolicy: &trackers.ArtifactPolicy{MaxPieceSizeMiB: 16}, + ImageHostPolicy: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"hdb"}, + OwnedHosts: []string{"hdb"}, + DisableWithoutRehost: true, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://tracker.hdbits.org"}, + CommentURLPatterns: []string{hdbBaseURL}, + DetailIDPattern: "id=(\\d+)", + }, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "HDB", + DisplayName: "HDB", + AuthKind: "passkey_cookies", + SupportsCookieFile: true, + RequiresPasskey: true, + }, + AuthPolicy: &trackers.AuthPolicy{PasskeyRequiresUsername: true, PasskeyRequiresCookie: true}, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, request api.TrackerAuthLoginRequest) error { + return resolveAuthSessionAt(ctx, cfg, dbPath, request, hdbBaseURL, nil) + }, + } +} + +// Definition extends the shared standalone definition with HDB data lookup and testable endpoints. +type Definition struct { + *standalone.Definition + baseURL string + httpClient *http.Client +} + +// New returns a fresh HDB definition from its tracker-local profile. +func New() *Definition { + return &Definition{Definition: standalone.MustNew(Profile()), baseURL: hdbBaseURL} +} diff --git a/internal/trackers/impl/standalone/hdb/upload.go b/internal/trackers/impl/standalone/hdb/upload.go new file mode 100644 index 00000000..be18273e --- /dev/null +++ b/internal/trackers/impl/standalone/hdb/upload.go @@ -0,0 +1,621 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdb + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + hdbBaseURL = "https://hdbits.org" + hdbUploadPath = "/upload/upload" +) + +var hdbSuccessURLPattern = regexp.MustCompile(`(?i)details\.php\?id=(\d+)&uploaded=\d+`) + +type preparedUploadState struct { + torrentPath string + description string + fields map[string]string + uploadURL string + cookies []*http.Cookie + body []byte + contentType string + passkey string + client *http.Client +} + +func uploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string, httpClient *http.Client) (api.UploadSummary, error) { + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, baseURL, httpClient) + }) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDB submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUploadAt( + ctx context.Context, + req trackers.PreparationInput, + baseURL string, + httpClient *http.Client, +) (trackers.PreparedOperation, error) { + select { + case <-ctx.Done(): + return trackers.PreparedOperation{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + username := strings.TrimSpace(req.TrackerConfig.Username) + passkey := strings.TrimSpace(req.TrackerConfig.Passkey) + if username == "" || passkey == "" { + return trackers.PreparedOperation{}, errors.New("trackers: HDB missing username/passkey") + } + + category := hdbCategoryID(req.Meta) + codec := hdbCodecID(req.Meta) + medium := hdbMediumID(req.Meta) + if category == 0 || codec == 0 || medium == 0 { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: HDB mapping failed category=%d codec=%d medium=%d", category, codec, medium) + } + + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: HDB description assets: %w", err) + } + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + descriptionText := strings.TrimSpace(assets.Description) + if !assets.Final { + descriptionText, err = BuildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets.Description, assets.MenuImages, assets.Screenshots) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + + torrentPath, err := resolveTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + + uploadURL := strings.TrimRight(baseURL, "/") + uploadURL += hdbUploadPath + + fields := buildUploadFields(req.Meta, req.Runtime.DescriptionConfig(), category, codec, medium, descriptionText) + preview := standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "HDB", + ReleaseName: resolveUploadName(req.Meta), + DescriptionGroup: "hdb", + Description: descriptionText, + Endpoint: uploadURL, + Payload: fields, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: torrentPath, + Present: strings.TrimSpace(torrentPath) != "", + }}, + }) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + + cookies, err := resolveHDBCookies(ctx, req.Runtime.DBPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + body, contentType, err := buildMultipartPayload(fields, torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + client := httpClient + if client == nil { + client = &http.Client{Timeout: 40 * time.Second} + } + state := preparedUploadState{ + torrentPath: torrentPath, + description: descriptionText, + fields: fields, + uploadURL: uploadURL, + cookies: cookies, + body: body, + contentType: contentType, + passkey: passkey, + client: client, + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state) + }, nil), nil +} + +func submitPreparedUpload(ctx context.Context, req trackers.PreparationInput, state preparedUploadState) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.uploadURL, bytes.NewReader(state.body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDB request build: %w", err) + } + httpReq.Header.Set("Content-Type", state.contentType) + httpReq.Header.Set("User-Agent", "upbrr") + for _, cookie := range state.cookies { + httpReq.AddCookie(cookie) + } + + resp, err := state.client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDB upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + matches := hdbSuccessURLPattern.FindStringSubmatch(finalURL) + if len(matches) < 2 { + bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("HDB", resp.StatusCode, finalURL, bodyPreview) + } + + torrentID := strings.TrimSpace(matches[1]) + trackerTorrentPath := "" + if torrentID != "" { + trackerTorrentPath, err = resolveTrackerTorrentPath(req.Meta, req.Runtime.DBPath, "HDB") + if err != nil { + return api.UploadSummary{}, err + } + if err := downloadPersonalizedTorrent( + ctx, + state.client, + state.uploadURL, + req.Meta, + trackerTorrentPath, + torrentID, + state.passkey, + state.cookies, + ); err != nil && + req.Logger != nil { + req.Logger.Warnf("trackers: HDB torrent redownload failed: %v", err) + } + } + + downloadURL := buildHDBDownloadURL(state.uploadURL, req.Meta, torrentID, state.passkey) + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "HDB", + TorrentID: torrentID, + DownloadURL: downloadURL, + TorrentPath: trackerTorrentPath, + }}, + }, nil +} + +func buildUploadFields(meta api.UploadSubject, appConfig config.Config, categoryID int, codecID int, mediumID int, description string) map[string]string { + fields := map[string]string{ + "name": resolveUploadName(meta), + "category": strconv.Itoa(categoryID), + "codec": strconv.Itoa(codecID), + "medium": strconv.Itoa(mediumID), + "origin": "0", + "descr": strings.TrimSpace(description), + "techinfo": "", + } + + if trackers.IsInternalGroup(appConfig, "HDB", meta) { + fields["origin"] = "1" + } + + if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + if strings.TrimSpace(meta.MediaInfoTextPath) != "" { + content, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err == nil { + fields["techinfo"] = strings.TrimSpace(string(content)) + } + } + } + + if isHDBTVCategory(meta) && meta.Identity.TVDBID != 0 { + fields["tvdb"] = strconv.Itoa(meta.Identity.TVDBID) + } + if imdb := resolveIMDbURL(meta); imdb != "" { + fields["imdb"] = imdb + } else { + fields["imdb"] = "0" + } + if isHDBTVCategory(meta) { + season := meta.SeasonInt + episode := meta.EpisodeInt + if season <= 0 { + season = 1 + } + if episode <= 0 { + episode = 1 + } + fields["tvdb_season"] = strconv.Itoa(season) + fields["tvdb_episode"] = strconv.Itoa(episode) + } + + return fields +} + +// isHDBTVCategory reports whether HDB upload payloads may include TVDB fields. +// Canonical movie identity suppresses TVDB fields even when episode facts exist. +func isHDBTVCategory(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func resolveUploadName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.ReleaseName); name != "" { + return name + } + if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { + return name + } + if name := strings.TrimSpace(meta.Filename); name != "" { + return name + } + return pathutil.Base(meta.SourcePath) +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + if value := strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL); value != "" { + if !strings.HasSuffix(value, "/") { + return value + "/" + } + return value + } + } + if meta.Identity.IMDBID != 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.Identity.IMDBID) + } + return "" +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: HDB write multipart field %q: %w", key, err) + } + } + + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: HDB open torrent file: %w", err) + } + defer file.Close() + + part, err := writer.CreateFormFile("file", filepath.Base(torrentPath)) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: HDB create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: HDB copy torrent file: %w", err) + } + + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: HDB close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveTorrentPath(meta api.UploadSubject, dbPath string) (string, error) { + candidates := []string{ + strings.TrimSpace(meta.TorrentPath), + strings.TrimSpace(meta.ClientTorrentPath), + strings.TrimSpace(meta.SourcePath), + } + for _, candidate := range candidates { + if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + + if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err == nil { + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err == nil { + guessed := filepath.Join(tmpDir, base+".torrent") + if info, err := os.Stat(guessed); err == nil && !info.IsDir() { + return guessed, nil + } + } + } + } + + return "", errors.New("trackers: HDB torrent file not found") +} + +func resolveTrackerTorrentPath(meta api.UploadSubject, dbPath string, tracker string) (string, error) { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "", errors.New("trackers: HDB tracker torrent path requires db path and source path") + } + + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: HDB tmp root: %w", err) + } + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: HDB tmp release dir: %w", err) + } + name := strings.ToLower(strings.TrimSpace(tracker)) + name = strings.NewReplacer("/", "-", "\\", "-", " ", "-").Replace(name) + if name == "" { + name = "tracker" + } + return filepath.Join(tmpDir, base+"."+name+".torrent"), nil +} + +func resolveHDBCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookiepkg.LoadTrackerHTTPCookies(ctx, dbPath, "HDB", "hdbits.org") + if err != nil { + return values, fmt.Errorf("trackers: HDB load cookies: %w", err) + } + return values, nil +} + +func downloadPersonalizedTorrent( + ctx context.Context, + client *http.Client, + uploadURL string, + meta api.UploadSubject, + torrentPath string, + torrentID string, + passkey string, + cookies []*http.Cookie, +) error { + downloadURL := buildHDBDownloadURL(uploadURL, meta, torrentID, passkey) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return fmt.Errorf("trackers: HDB create personalized torrent request: %w", err) + } + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("trackers: HDB download personalized torrent: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("trackers: HDB read personalized torrent response: %w", err) + } + if len(body) == 0 { + return errors.New("empty torrent response") + } + if err := os.MkdirAll(filepath.Dir(torrentPath), 0o700); err != nil { + return fmt.Errorf("trackers: HDB create torrent output dir: %w", err) + } + if err := os.WriteFile(torrentPath, body, 0o600); err != nil { + return fmt.Errorf("trackers: HDB write torrent output: %w", err) + } + return nil +} + +func buildHDBDownloadURL(uploadURL string, meta api.UploadSubject, torrentID string, passkey string) string { + if strings.TrimSpace(torrentID) == "" || strings.TrimSpace(passkey) == "" { + return "" + } + base := strings.TrimSuffix(uploadURL, hdbUploadPath) + filePart := pathutil.Base(meta.SourcePath) + if filePart == "" || filePart == "." || filePart == string(filepath.Separator) { + filePart = "download" + } + return fmt.Sprintf( + "%s/download.php/%s?id=%s&passkey=%s", + strings.TrimRight(base, "/"), + url.PathEscape(filePart), + url.QueryEscape(torrentID), + url.QueryEscape(passkey), + ) +} + +func hdbCategoryID(meta api.UploadSubject) int { + category, _ := meta.Identity.RequireCategory() + switch category { + case api.CanonicalCategoryMovie: + return 1 + case api.CanonicalCategoryTV: + return 2 + case api.CanonicalCategoryUnknown: + } + genres := "" + keywords := "" + if meta.ProviderMetadata.TMDB != nil { + genres = strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres)) + keywords = strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords)) + } + if strings.Contains(genres, "documentary") || strings.Contains(keywords, "documentary") { + return 3 + } + if meta.ProviderMetadata.IMDB != nil { + imdbType := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.IMDB.Type)) + imdbGenres := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres)) + if strings.Contains(imdbType, "concert") || (strings.Contains(imdbType, "video") && strings.Contains(imdbGenres, "music")) { + return 4 + } + } + return 0 +} + +func hdbCodecID(meta api.UploadSubject) int { + codec := strings.ToUpper(strings.TrimSpace(meta.VideoCodec)) + if codec == "" { + codec = strings.ToUpper(strings.TrimSpace(meta.VideoEncode)) + } + switch codec { + case "AVC", "H.264": + return 1 + case "MPEG-2": + return 2 + case "VC-1": + return 3 + case "XVID": + return 4 + case "HEVC", "H.265": + return 5 + case "VP9": + return 6 + default: + return 0 + } +} + +func hdbMediumID(meta api.UploadSubject) int { + discType := strings.ToUpper(strings.TrimSpace(meta.DiscType)) + contentType := resolveHDBType(meta) + if discType == "BDMV" || discType == "HD DVD" { + return 1 + } + if contentType == "HDTV" { + if meta.HasEncodeSettings { + return 3 + } + return 4 + } + switch contentType { + case "ENCODE", "WEBRIP": + return 3 + case "REMUX": + return 5 + case "WEBDL": + return 6 + default: + return 0 + } +} + +func resolveHDBType(meta api.UploadSubject) string { + typeValue := normalizeHDBType(meta.Type) + if typeValue == "" || isHDBCategoryType(typeValue) { + if meta.ReleaseNameOverrides.Type != nil { + typeValue = normalizeHDBType(*meta.ReleaseNameOverrides.Type) + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = normalizeHDBType(meta.Release.Type) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.DiscType) != "" { + typeValue = "DISC" + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = inferHDBTypeFromSource(meta.Source) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + typeValue = inferHDBTypeFromPath(meta.SourcePath) + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.VideoEncode) != "" { + typeValue = "ENCODE" + } + } + if typeValue == "" || isHDBCategoryType(typeValue) { + if strings.TrimSpace(meta.VideoCodec) != "" || strings.TrimSpace(meta.Release.Resolution) != "" || strings.TrimSpace(meta.Release.Ext) != "" { + typeValue = "ENCODE" + } + } + return typeValue +} + +func normalizeHDBType(value string) string { + upper := strings.ToUpper(strings.TrimSpace(value)) + if upper == "" { + return "" + } + upper = strings.ReplaceAll(upper, "-", "") + upper = strings.ReplaceAll(upper, " ", "") + upper = strings.ReplaceAll(upper, "_", "") + switch upper { + case "WEBDL": + return "WEBDL" + case "WEBRIP": + return "WEBRIP" + } + return upper +} + +func isHDBCategoryType(value string) bool { + upper := strings.ToUpper(strings.TrimSpace(value)) + return upper == "MOVIE" || upper == "TV" +} + +func inferHDBTypeFromSource(source string) string { + upper := normalizeHDBType(source) + switch { + case strings.Contains(upper, "WEBDL"): + return "WEBDL" + case strings.Contains(upper, "WEBRIP"): + return "WEBRIP" + case strings.Contains(upper, "HDTV"): + return "HDTV" + } + return "" +} + +func inferHDBTypeFromPath(path string) string { + base := strings.ToUpper(strings.TrimSpace(path)) + compact := strings.NewReplacer(".", "", "-", "", "_", "", " ", "").Replace(base) + switch { + case strings.Contains(compact, "REMUX"): + return "REMUX" + case strings.Contains(compact, "WEBDL"): + return "WEBDL" + case strings.Contains(compact, "WEBRIP"): + return "WEBRIP" + case strings.Contains(compact, "HDTV"): + return "HDTV" + case strings.Contains(compact, "DVDRIP"): + return "DVDRIP" + } + return "" +} diff --git a/internal/trackers/impl/standalone/hds/bbcode.go b/internal/trackers/impl/standalone/hds/bbcode.go new file mode 100644 index 00000000..b61eec04 --- /dev/null +++ b/internal/trackers/impl/standalone/hds/bbcode.go @@ -0,0 +1,37 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + replacer := strings.NewReplacer( + "[user]", "", "[/user]", "", + "[align=left]", "", "[/align]", "", + "[right]", "", "[/right]", "", + "[align=right]", "", "[/align]", "", + "[alert]", "", "[/alert]", "", + "[note]", "", "[/note]", "", + "[hr]", "", "[/hr]", "", + "[h1]", "[u][b]", "[/h1]", "[/b][/u]", + "[h2]", "[u][b]", "[/h2]", "[/b][/u]", + "[h3]", "[u][b]", "[/h3]", "[/b][/u]", + "[ul]", "", "[/ul]", "", + "[ol]", "", "[/ol]", "", + ) + value = replacer.Replace(value) + value = bbcode.RemoveSub(value) + value = bbcode.RemoveSup(value) + value = bbcode.RemoveHide(value) + value = bbcode.RemoveImageResize(value) + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveSpoiler(value) + value = bbcode.RemoveColor(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/hds/description_assets_test.go b/internal/trackers/impl/standalone/hds/description_assets_test.go new file mode 100644 index 00000000..0da793e2 --- /dev/null +++ b/internal/trackers/impl/standalone/hds/description_assets_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{ + RawURL: "https://images.example.invalid/menu.png", + ImgURL: "https://images.example.invalid/menu-thumb.png", + WebURL: "https://images.example.invalid/menu-view", + }}, + Screenshots: []api.ScreenshotImage{{ + RawURL: "https://images.example.invalid/normal.png", + ImgURL: "https://images.example.invalid/normal-thumb.png", + WebURL: "https://images.example.invalid/normal-view", + }}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "HDS", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + ScreenshotHeader: "Screenshots token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu-thumb.png", "Screenshots token", "normal-thumb.png") + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "HDS", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/hds/dupe.go b/internal/trackers/impl/standalone/hds/dupe.go new file mode 100644 index 00000000..7f65dc9c --- /dev/null +++ b/internal/trackers/impl/standalone/hds/dupe.go @@ -0,0 +1,119 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +var hdsPagePattern = regexp.MustCompile(`pages=(\d+)`) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + resolution := strings.TrimSpace(meta.Release.Resolution) + if resolution != "2160p" && resolution != "1080p" && resolution != "1080i" && resolution != "720p" { + return dupe.NotRun(dupe.NotRunUnsupportedContent, "resolution below HDS dupe-check minimum", nil) + } + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for HDS dupe search", nil) + } + baseURL := hdsBaseURL(s.cfg) + parsed, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "HDS search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "HDS", parsed.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid HDS cookies", nil) + } + entries := make([]api.DupeEntry, 0) + for page := 0; page <= 10; page++ { + params := url.Values{ + "page": {"torrents"}, + "search": {fmt.Sprintf("tt%07d", meta.Identity.IMDBID)}, + "active": {"0"}, + "options": {"2"}, + "pages": {strconv.Itoa(page)}, + } + status, body, err := commonhttp.GetText(ctx, s.http, baseURL+"/index.php", params, trackerCookies) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureRequest, "HDS search failed", err) + } + parts := strings.SplitN(body, "Show/Hide Categories", 2) + if len(parts) < 2 { + break + } + root, err := xhtml.Parse(strings.NewReader(parts[1])) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "HDS response parse failed", err) + } + before := len(entries) + for _, row := range commonhttp.FindNodes(root, func(node *xhtml.Node) bool { return node.Type == xhtml.ElementNode && node.Data == "tr" }) { + nameNode := commonhttp.FirstNode(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(commonhttp.Attr(node, "href"), "page=torrent-details") + }) + if nameNode == nil { + continue + } + entry := api.DupeEntry{ + Name: metautil.FirstNonEmptyTrimmed(commonhttp.NodeText(nameNode), commonhttp.Attr(nameNode, "title")), + Link: commonhttp.AbsoluteURL(baseURL, commonhttp.Attr(nameNode, "href")), + } + for _, cell := range commonhttp.FindNodes(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "td" && commonhttp.HasClass(node, "lista") + }) { + if size, ok := commonhttp.ParseSizeBytes(commonhttp.NodeText(cell)); ok { + entry.SizeText = strings.TrimSpace(commonhttp.NodeText(cell)) + entry.SizeKnown, entry.SizeBytes = true, size + break + } + } + if entry.Name != "" { + entries = append(entries, entry) + } + } + next := commonhttp.FirstNode(root, func(node *xhtml.Node) bool { + if node.Type != xhtml.ElementNode || node.Data != "a" { + return false + } + href, text := commonhttp.Attr(node, "href"), strings.TrimSpace(commonhttp.NodeText(node)) + return strings.Contains(href, "pages=") && (strings.EqualFold(text, "Next") || text == ">>" || hdsPagePattern.MatchString(href)) + }) + if next == nil || len(entries) == before { + break + } + } + return dupe.Resolved(entries, nil) +} + +func hdsBaseURL(_ config.Config) string { + return "https://hd-space.org" +} diff --git a/internal/trackers/impl/standalone/hds/profile.go b/internal/trackers/impl/standalone/hds/profile.go new file mode 100644 index 00000000..252db2d0 --- /dev/null +++ b/internal/trackers/impl/standalone/hds/profile.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns HDS identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "HDS", + BaseURL: baseURL, + DescriptionGroup: "hds", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: sourceFlag, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"hd-space.pw"}, + }, + AuthCapability: &api.TrackerAuthCapability{ + AuthKind: "cookies", + SupportsCookieFile: true, + }, + } +} + +// New returns a fresh HDS definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "hds", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/hds/rules.go b/internal/trackers/impl/standalone/hds/rules.go new file mode 100644 index 00000000..f6486fe6 --- /dev/null +++ b/internal/trackers/impl/standalone/hds/rules.go @@ -0,0 +1,40 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +// rules requires a supported HD resolution as a strict block and reports a +// missing IMDb identifier as waivable. +func rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkRules} } + +func checkRules(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + failures := make([]api.RuleFailure, 0, 2) + resolution := trackers.ResolveRuleResolution(meta) + if !supportsHDSResolution(resolution) { + failures = append(failures, trackers.NewRuleFailure( + "min_resolution", + "resolution must be at least 720p", + api.RuleDispositionStrict, + )) + } + hasIMDb := meta.Identity.IMDBID > 0 + if !hasIMDb && meta.ProviderMetadata.IMDB != nil { + hasIMDb = meta.ProviderMetadata.IMDB.IMDBID > 0 || strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) != "" + } + if !hasIMDb { + failures = append(failures, trackers.NewRuleFailure("require_imdb", "missing IMDb ID", api.RuleDispositionWaivable)) + } + return failures, nil +} diff --git a/internal/trackers/impl/standalone/hds/upload.go b/internal/trackers/impl/standalone/hds/upload.go new file mode 100644 index 00000000..ba96d0ef --- /dev/null +++ b/internal/trackers/impl/standalone/hds/upload.go @@ -0,0 +1,431 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/cookies" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://hd-space.org" + uploadURL = baseURL + "/index.php?page=upload" + torrentURL = baseURL + "/index.php?page=torrent-details&id=" + sourceFlag = "HD-Space" +) + +var idPattern = regexp.MustCompile(`download\.php\?id=([a-zA-Z0-9]+)`) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + nfo *commonhttp.FileField +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if !supportsHDSResolution(trackers.ResolveRuleResolution(api.NewRuleSubject(req.Meta))) { + return trackers.PreparedOperation{}, errors.New("trackers: HDS resolution must be at least 720p") + } + files := []commonhttp.FileField{{ + FieldName: "torrent", + FileName: filepath.Base(state.torrentPath), + Path: state.torrentPath, + }} + if state.nfo != nil { + files = append(files, *state.nfo) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + artifactPath := "" + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "HDS") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDS request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + + result, err := commonhttp.ExecuteUpload(httpclient.New(httpclient.DefaultTimeout), httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "HDS", + SuccessStatus: func(status int) bool { return status >= 200 && status < 400 }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDS execute upload: %w", err) + } + combined := result.FinalURL + "\n" + string(result.Body) + match := idPattern.FindStringSubmatch(combined) + if result.Success && len(match) >= 2 { + id := strings.TrimSpace(match[1]) + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "HDS", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: baseURL + "/download.php?id=" + id, + TorrentPath: artifactPath, + }}, + }, nil + } + + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "HDS", "upload_failure", result.Preview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("HDS", result.StatusCode, result.Preview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "HDS", + ReleaseName: state.releaseName, + DescriptionGroup: "hds", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, []*http.Cookie, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + cookies, err := loadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, err + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + fields := map[string]string{ + "category": strconv.Itoa(resolveCategoryID(req.Meta)), + "filename": metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Filename, pathutil.Base(req.Meta.SourcePath)), + "genre": resolveGenres(req.Meta), + "imdb": resolveIMDbURL(req.Meta), + "info": description, + "nuk_rea": "", + "nuk": "false", + "req": "false", + "submit": "Send", + "t3d": boolString(req.Meta.Is3D != ""), + "user_id": "", + "youtube_video": resolveYouTube(req.Meta), + "anonymous": boolString(req.TrackerConfig.Anon), + } + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: fields["filename"], + fields: fields, + } + if file, ok := resolveNFO(req.Meta); ok { + state.nfo = &file + } + return state, cookies, nil +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "HDS", "hd-space.org") + if err != nil { + return values, fmt.Errorf("trackers: HDS load cookies: %w", err) + } + return values, nil +} + +func resolveCategoryID(meta api.UploadSubject) int { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return 15 + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + return 40 + } + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + if strings.Contains(strings.ToLower(resolveGenres(meta)+" "+resolveKeywords(meta)), "documentary") { + if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "2160p") { + return 47 + } + if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "1080p") || strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "1080i") { + return 25 + } + return 24 + } + if meta.Anime { + switch strings.TrimSpace(meta.Release.Resolution) { + case "2160p": + return 48 + case "1080p", "1080i": + return 28 + default: + return 27 + } + } + if category == "TV" { + switch strings.TrimSpace(meta.Release.Resolution) { + case "2160p": + return 45 + case "1080p", "1080i": + return 22 + default: + return 21 + } + } + switch strings.TrimSpace(meta.Release.Resolution) { + case "2160p": + return 46 + case "1080p", "1080i": + return 19 + default: + return 18 + } +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + parts := make([]string, 0, 12) + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if req.Runtime.Description.AddLogo { + if logo := resolveLogo(meta); logo != "" { + parts = append(parts, "[center][img]"+logo+"[/img][/center]") + } + } + + // TV Episode details + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") + } + + // File information (BDInfo or MediaInfo) + if media := trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta); media != "" { + parts = append(parts, "[pre]"+media+"[/pre]") + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // menu + if len(assets.MenuImages) > 0 { + // header + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + // images + if shots := screenshotBlock(assets.MenuImages); shots != "" { + parts = append(parts, shots) + } + } + // Screenshot Header + if header := strings.TrimSpace(req.Runtime.Description.ScreenshotHeader); header != "" { + parts = append(parts, header) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // screenshots + if shots := screenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + + // custom user signature + if signature := strings.TrimSpace(req.Runtime.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + // upbrr signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[center][url=%s][size=2]%s[/size][/url][/center]", link, text)) + + // finalize description + finalDescription := finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "HDS", req.Runtime.DBPath, finalDescription, req.Logger) + } + + return finalDescription +} + +func resolveLogo(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo) != "" { + return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo), "/") + } + return "" +} + +func resolveGenres(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + case meta.ProviderMetadata.IMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + default: + return strings.TrimSpace(meta.Release.Genre) + } +} + +func resolveKeywords(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) + } + return "" +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) != "" { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveYouTube(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + return "" +} + +func resolveNFO(meta api.UploadSubject) (commonhttp.FileField, bool) { + dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) + payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") + if err != nil { + return commonhttp.FileField{}, false + } + return commonhttp.FileField{ + FieldName: "nfo", + FileName: filepath.Base(path), + Content: payload, + }, true +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + var sb strings.Builder + for _, image := range images { + if strings.TrimSpace(image.WebURL) == "" || strings.TrimSpace(image.ImgURL) == "" { + continue + } + sb.WriteString("[url=" + image.WebURL + "][img]" + image.ImgURL + "[/img][/url]") + + // HDS cannot resize images. If the image host does not provide small thumbnails(<400px), place only one image per line. + // imgbox provides small thumbnails, so we can place them side-by-side. + if !strings.Contains(strings.ToLower(image.WebURL), "imgbox") { + sb.WriteString("\n") + } + } + content := strings.TrimSpace(sb.String()) + if content == "" { + return "" + } + return "[center]\n" + content + "\n[/center]" +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} + +func supportsHDSResolution(value string) bool { + switch strings.TrimSpace(value) { + case "2160p", "1080p", "1080i", "720p": + return true + default: + return false + } +} diff --git a/internal/trackers/impl/standalone/hds/upload_test.go b/internal/trackers/impl/standalone/hds/upload_test.go new file mode 100644 index 00000000..4c858f4f --- /dev/null +++ b/internal/trackers/impl/standalone/hds/upload_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hds + +import "testing" + +func TestSupportsHDSResolution(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + resolution string + expected bool + }{ + { + name: "2160p", + resolution: "2160p", + expected: true, + }, + { + name: "1080i", + resolution: "1080i", + expected: true, + }, + { + name: "720p", + resolution: "720p", + expected: true, + }, + { + name: "576p", + resolution: "576p", + expected: false, + }, + { + name: "480p", + resolution: "480p", + expected: false, + }, + } + + for _, tc := range cases { + if got := supportsHDSResolution(tc.resolution); got != tc.expected { + t.Fatalf("%s: expected %t, got %t", tc.name, tc.expected, got) + } + } +} diff --git a/internal/trackers/impl/standalone/hdt/bbcode.go b/internal/trackers/impl/standalone/hdt/bbcode.go new file mode 100644 index 00000000..2a1d2c20 --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/bbcode.go @@ -0,0 +1,36 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + replacer := strings.NewReplacer( + "[user]", "", "[/user]", "", + "[align=left]", "", "[/align]", "", + "[align=right]", "", "[/align]", "", + "[alert]", "", "[/alert]", "", + "[note]", "", "[/note]", "", + "[hr]", "", "[/hr]", "", + "[h1]", "[u][b]", "[/h1]", "[/b][/u]", + "[h2]", "[u][b]", "[/h2]", "[/b][/u]", + "[h3]", "[u][b]", "[/h3]", "[/b][/u]", + "[ul]", "", "[/ul]", "", + "[ol]", "", "[/ol]", "", + ) + value = replacer.Replace(value) + value = bbcode.RemoveSub(value) + value = bbcode.RemoveSup(value) + value = bbcode.ConvertSpoilerToHide(value) + value = bbcode.RemoveImageResize(value) + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveSpoiler(value) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/hdt/description_assets_test.go b/internal/trackers/impl/standalone/hdt/description_assets_test.go new file mode 100644 index 00000000..114d315d --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/description_assets_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{ + RawURL: "https://images.example.invalid/menu.png", + ImgURL: "https://images.example.invalid/menu-thumb.png", + }}, + Screenshots: []api.ScreenshotImage{{ + RawURL: "https://images.example.invalid/normal.png", + ImgURL: "https://images.example.invalid/normal-thumb.png", + }}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "HDT", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + ScreenshotHeader: "Screenshots token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "HDT", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/hdt/dupe.go b/internal/trackers/impl/standalone/hdt/dupe.go new file mode 100644 index 00000000..c4bab814 --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/dupe.go @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + resolution := strings.TrimSpace(meta.Release.Resolution) + if resolution != "2160p" && resolution != "1080p" && resolution != "1080i" && resolution != "720p" { + return dupe.NotRun(dupe.NotRunUnsupportedContent, "resolution below HDT dupe-check minimum", nil) + } + baseURL := hdtBaseURL(s.cfg) + parsed, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "HDT search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "HDT", parsed.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid HDT cookies", nil) + } + params := url.Values{"active": {"0"}, "category[]": {strconv.Itoa(hdtCategoryID(meta))}} + if meta.Identity.IMDBID != 0 { + params.Set("search", fmt.Sprintf("tt%07d", meta.Identity.IMDBID)) + params.Set("options", "2") + } else { + params.Set("search", metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName)) + params.Set("options", "3") + } + status, root, err := commonhttp.GetHTML(ctx, s.http, baseURL+"/torrents.php", params, trackerCookies) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices || root == nil { + return dupe.Failed(dupe.FailureRequest, "HDT search failed", err) + } + entries := make([]api.DupeEntry, 0) + for _, row := range commonhttp.FindNodes(root, func(node *xhtml.Node) bool { return node.Type == xhtml.ElementNode && node.Data == "tr" }) { + nameNode := commonhttp.FirstNode(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(commonhttp.Attr(node, "href"), "details.php?id=") + }) + if nameNode == nil { + continue + } + entry := api.DupeEntry{Name: strings.TrimSpace(commonhttp.NodeText(nameNode)), Link: commonhttp.AbsoluteURL(baseURL, commonhttp.Attr(nameNode, "href"))} + for _, cell := range commonhttp.FindNodes(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "td" && commonhttp.HasClass(node, "mainblockcontent") + }) { + if size, ok := commonhttp.ParseSizeBytes(commonhttp.NodeText(cell)); ok { + entry.SizeKnown, entry.SizeBytes = true, size + break + } + } + if entry.Name != "" { + entries = append(entries, entry) + } + } + return dupe.Resolved(entries, nil) +} + +func hdtBaseURL(_ config.Config) string { + return "https://hd-torrents.me" +} +func hdtCategoryID(meta api.DuplicateSubject) int { + category := strings.ToUpper(strings.TrimSpace(string(meta.Identity.Category))) + resolution := strings.TrimSpace(meta.Release.Resolution) + disc := strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") + remux := strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") + uhd := strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && resolution == "2160p" + if category == "TV" { + if disc { + if resolution == "2160p" { + return 72 + } + return 59 + } + if remux { + if uhd { + return 73 + } + return 60 + } + if resolution == "2160p" { + return 65 + } + if resolution == "1080p" || resolution == "1080i" { + return 30 + } + return 38 + } + if disc { + if resolution == "2160p" { + return 70 + } + return 1 + } + if remux { + if uhd { + return 71 + } + return 2 + } + if resolution == "2160p" { + return 64 + } + if resolution == "1080p" || resolution == "1080i" { + return 5 + } + return 3 +} diff --git a/internal/trackers/impl/standalone/hdt/profile.go b/internal/trackers/impl/standalone/hdt/profile.go new file mode 100644 index 00000000..ac3f100b --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/profile.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns HDT identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "HDT", + BaseURL: resolveBaseURL(), + DescriptionGroup: "hdt", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{ + Source: "hd-torrents.org", + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://hdts-announce.ru"}, + }, + AuthCapability: &api.TrackerAuthCapability{ + AuthKind: "cookies", + SupportsCookieFile: true, + }, + } +} + +// New returns a fresh HDT definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "hdt", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/hdt/rules.go b/internal/trackers/impl/standalone/hdt/rules.go new file mode 100644 index 00000000..920802ed --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/rules.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "context" + "fmt" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +// rules strictly blocks uploads whose resolution is unknown. +func rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkRules} } + +func checkRules(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + if trackers.ResolveRuleResolution(meta) == "" { + return []api.RuleFailure{trackers.NewRuleFailure( + "resolution_required", + "missing resolution", + api.RuleDispositionStrict, + )}, nil + } + return nil, nil +} diff --git a/internal/trackers/impl/standalone/hdt/upload.go b/internal/trackers/impl/standalone/hdt/upload.go new file mode 100644 index 00000000..ed0be1a6 --- /dev/null +++ b/internal/trackers/impl/standalone/hdt/upload.go @@ -0,0 +1,455 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hdt + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/cookies" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +var tokenPattern = regexp.MustCompile(`name="csrfToken"\s+value="([^"]+)"`) +var successPattern = regexp.MustCompile(`details\.php\?id=([a-zA-Z0-9]+)|Upload successful!`) +var detailsPattern = regexp.MustCompile(`details\.php\?id=([a-zA-Z0-9]+)`) + +type uploadState struct { + baseURL string + torrentPath string + description string + releaseName string + fields map[string]string + nfo *commonhttp.FileField + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req, req.Intent != trackers.PreparationIntentUpload) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if trackers.ResolveRuleResolution(api.NewRuleSubject(req.Meta)) == "" { + return trackers.PreparedOperation{}, errors.New("trackers: HDT missing resolution") + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: HDT %s", state.blockedReason) + } + files := []commonhttp.FileField{{ + FieldName: "torrent", + FileName: filepath.Base(state.torrentPath), + Path: state.torrentPath, + }} + if state.nfo != nil { + files = append(files, *state.nfo) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + artifactPath := "" + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "HDT") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.baseURL+"/upload.php", bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDT request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + + result, err := commonhttp.ExecuteUpload(httpclient.New(httpclient.DefaultTimeout), httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "HDT", + SuccessStatus: func(status int) bool { return status >= 200 && status < 400 }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: HDT execute upload: %w", err) + } + combined := result.FinalURL + "\n" + string(result.Body) + id := "" + if match := detailsPattern.FindStringSubmatch(combined); len(match) >= 2 { + id = match[1] + } + if result.Success && successPattern.MatchString(combined) { + tURL := result.FinalURL + if id != "" && !strings.Contains(tURL, "details.php?id=") { + tURL = state.baseURL + "/details.php?id=" + id + } + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, "hd-torrents.org"); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "HDT", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}, + }, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "HDT", "upload_failure", result.Preview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("HDT", result.StatusCode, result.Preview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "HDT", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "hdt", + Description: state.description, + Endpoint: state.baseURL + "/upload.php", + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput, dryRun bool) (uploadState, []*http.Cookie, error) { + base := resolveBaseURL() + cookies, err := loadCookies(ctx, req.Runtime.DBPath, base) + if err != nil { + return uploadState{}, nil, err + } + token := strings.Join([]string{"dry", "run", "token"}, "-") + if !dryRun { + token, err = fetchToken(ctx, base, cookies) + if err != nil { + return uploadState{}, nil, err + } + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + fields := map[string]string{ + "filename": resolveName(req.Meta), + "category": strconv.Itoa(resolveCategoryID(req.Meta)), + "info": description, + "csrfToken": token, + "season": boolString(req.Meta.TVPack), + "anonymous": boolString(req.TrackerConfig.Anon), + } + if req.Meta.Is3D != "" { + fields["3d"] = "true" + } + hdr := strings.ToUpper(strings.TrimSpace(req.Meta.HDR)) + if strings.Contains(hdr, "HDR10+") { + fields["HDR10"] = "true" + fields["HDR10Plus"] = "true" + } else if strings.Contains(hdr, "HDR") { + fields["HDR10"] = "true" + } + if strings.Contains(hdr, "DV") { + fields["DolbyVision"] = "true" + } + if imdb := resolveIMDbURL(req.Meta); imdb != "" { + fields["infosite"] = imdb + "/" + } + state := uploadState{ + baseURL: base, + torrentPath: torrentPath, + description: description, + releaseName: fields["filename"], + fields: fields, + } + if file, ok := resolveNFO(req.Meta); ok { + state.nfo = &file + } + return state, cookies, nil +} + +func resolveBaseURL() string { + return "https://hd-torrents.me" +} + +func loadCookies(ctx context.Context, dbPath string, baseURL string) ([]*http.Cookie, error) { + host := "hd-torrents.me" + if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" { + host = parsed.Host + } + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "HDT", host) + if err != nil { + return values, fmt.Errorf("trackers: HDT load cookies: %w", err) + } + return values, nil +} + +func fetchToken(ctx context.Context, baseURL string, cookies []*http.Cookie) (string, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/upload.php", nil) + if err != nil { + return "", fmt.Errorf("trackers: HDT token request build: %w", err) + } + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return "", fmt.Errorf("trackers: HDT token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + match := tokenPattern.FindStringSubmatch(string(body)) + if len(match) < 2 { + return "", errors.New("trackers: HDT csrf token not found") + } + return strings.TrimSpace(match[1]), nil +} + +func resolveCategoryID(meta api.UploadSubject) int { + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + resolution := strings.TrimSpace(meta.Release.Resolution) + if category == "TV" { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") { + if resolution == "2160p" { + return 72 + } + return 59 + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && resolution == "2160p" { + return 73 + } + return 60 + } + switch resolution { + case "2160p": + return 65 + case "1080p", "1080i": + return 30 + default: + return 38 + } + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") { + if resolution == "2160p" { + return 70 + } + return 1 + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && resolution == "2160p" { + return 71 + } + return 2 + } + switch resolution { + case "2160p": + return 64 + case "1080p", "1080i": + return 5 + default: + return 3 + } +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + parts := make([]string, 0, 15) + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if req.Runtime.Description.AddLogo { + if logo := resolveLogo(meta); logo != "" { + parts = append(parts, "[center][img]"+logo+"[/img][/center]") + } + } + + // TV Episode details + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") + } + + // File information (BDInfo or MediaInfo) + if media := trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta); media != "" { + parts = append(parts, "[pre]"+media+"[/pre]") + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // menu + if len(assets.MenuImages) > 0 { + // header + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + // images + if shots := screenshotBlock(assets.MenuImages); shots != "" { + parts = append(parts, shots) + } + } + // Screenshot Header + if header := strings.TrimSpace(req.Runtime.Description.ScreenshotHeader); header != "" { + parts = append(parts, header) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // screenshots + if shots := screenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + + // custom user signature + if signature := strings.TrimSpace(req.Runtime.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + // upbrr signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[right][url=%s][size=1]%s[/size][/url][/right]", link, text)) + + // finalize description + finalDescription := finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "HDT", req.Runtime.DBPath, finalDescription, req.Logger) + } + + return finalDescription +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := make([]string, 0, len(images)) + for _, image := range images { + if strings.TrimSpace(image.RawURL) == "" { + continue + } + parts = append(parts, " ") + } + if len(parts) == 0 { + return "" + } + return "[center]" + strings.Join(parts, " ") + "[/center]" +} + +func resolveName(meta api.UploadSubject) string { + name := strings.TrimSpace(meta.ReleaseName) + if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") || strings.EqualFold(strings.TrimSpace(meta.Type), "WEBRIP") || + strings.EqualFold(strings.TrimSpace(meta.Type), "ENCODE") { + name = strings.Replace(name, meta.Audio, strings.Replace(meta.Audio, " ", "", 1), 1) + } + name = strings.ReplaceAll(name, " DV ", " DoVi ") + name = strings.ReplaceAll(name, "BluRay REMUX", "Blu-ray Remux") + name = strings.Join(strings.Fields(name), " ") + name = strings.ReplaceAll(name, ":", "") + return strings.TrimSpace(name) +} + +func resolveLogo(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo) != "" { + return "https://image.tmdb.org/t/p/w300/" + strings.TrimPrefix(strings.TrimSpace(meta.ProviderMetadata.TMDB.TMDBLogo), "/") + } + return "" +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveNFO(meta api.UploadSubject) (commonhttp.FileField, bool) { + dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) + payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") + if err != nil { + return commonhttp.FileField{}, false + } + return commonhttp.FileField{ + FieldName: "nfos", + FileName: filepath.Base(path), + Content: payload, + }, true +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/internal/trackers/impl/standalone/internal/cookieauth/login.go b/internal/trackers/impl/standalone/internal/cookieauth/login.go new file mode 100644 index 00000000..0252a6a9 --- /dev/null +++ b/internal/trackers/impl/standalone/internal/cookieauth/login.go @@ -0,0 +1,77 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package cookieauth + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/trackers" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + "github.com/autobrr/upbrr/pkg/api" +) + +// CookieLoginSpec describes one stored-cookie validation and credential-login lifecycle. +type CookieLoginSpec struct { + TrackerID string + BaseURL string + CookieDomain string + Load func(context.Context, string) ([]*http.Cookie, error) + Validate func(context.Context, string, []*http.Cookie) error + HasCredential func(config.TrackerConfig) bool + Login func(context.Context, config.TrackerConfig, string, string) error +} + +// CookieLoginResolver returns a resolver that reuses valid cookies, preserves +// transient validation failures, and logs in only after confirmed invalid or +// missing cookie state when credentials are available. +func CookieLoginResolver(spec CookieLoginSpec) trackers.AuthSessionResolver { + trackerID := strings.ToUpper(strings.TrimSpace(spec.TrackerID)) + baseURL := strings.TrimRight(strings.TrimSpace(spec.BaseURL), "/") + if spec.Load == nil { + spec.Load = func(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + return cookies.LoadTrackerHTTPCookies(ctx, dbPath, trackerID, spec.CookieDomain) + } + } + return func(ctx context.Context, cfg config.TrackerConfig, dbPath string, _ api.TrackerAuthLoginRequest) error { + return resolveCookieLogin(ctx, cfg, dbPath, trackerID, baseURL, spec) + } +} + +func resolveCookieLogin( + ctx context.Context, + cfg config.TrackerConfig, + dbPath string, + trackerID string, + baseURL string, + spec CookieLoginSpec, +) error { + values, loadErr := spec.Load(ctx, dbPath) + if loadErr == nil && len(values) > 0 { + validationErr := spec.Validate(ctx, baseURL, values) + if validationErr == nil { + return nil + } + var validation *trackerauth.ValidationError + if !errors.As(validationErr, &validation) || !validation.ConfirmedInvalid || validation.Transient || !spec.HasCredential(cfg) { + return validationErr + } + } + if !spec.HasCredential(cfg) { + if loadErr == nil { + loadErr = cookies.ErrTrackerCookiesNotFound + } + return &trackerauth.AuthRequiredError{ + TrackerID: trackerID, + Reason: "cookies or username/password missing", + Err: fmt.Errorf("trackers: %s cookies unavailable: %w", trackerID, loadErr), + } + } + return spec.Login(ctx, cfg, dbPath, baseURL) +} diff --git a/internal/trackers/impl/standalone/internal/cookieauth/login_test.go b/internal/trackers/impl/standalone/internal/cookieauth/login_test.go new file mode 100644 index 00000000..c41ec868 --- /dev/null +++ b/internal/trackers/impl/standalone/internal/cookieauth/login_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package cookieauth + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestCookieLoginResolverLifecycle(t *testing.T) { + t.Parallel() + + transientErr := &trackerauth.ValidationError{ + TrackerID: "FF", + Transient: true, + Reason: "unavailable", + } + tests := []struct { + name string + load func(context.Context, string) ([]*http.Cookie, error) + validationErr error + hasCredentials bool + wantLoginCalls int32 + wantValidationErr bool + wantAuthRequired bool + }{ + { + name: "valid stored cookies", + load: func(context.Context, string) ([]*http.Cookie, error) { + return []*http.Cookie{{Name: "session", Value: "valid"}}, nil + }, + hasCredentials: true, + }, + { + name: "confirmed invalid cookies login", + load: func(context.Context, string) ([]*http.Cookie, error) { + return []*http.Cookie{{Name: "session", Value: "invalid"}}, nil + }, + validationErr: &trackerauth.ValidationError{ + TrackerID: "FF", + ConfirmedInvalid: true, + Reason: "invalid", + }, + hasCredentials: true, + wantLoginCalls: 1, + }, + { + name: "transient validation failure", + load: func(context.Context, string) ([]*http.Cookie, error) { + return []*http.Cookie{{Name: "session", Value: "unknown"}}, nil + }, + validationErr: transientErr, + hasCredentials: true, + wantValidationErr: true, + }, + { + name: "missing credentials", + load: func(context.Context, string) ([]*http.Cookie, error) { + return nil, cookies.ErrTrackerCookiesNotFound + }, + wantAuthRequired: true, + }, + { + name: "missing cookies login", + load: func(context.Context, string) ([]*http.Cookie, error) { + return nil, cookies.ErrTrackerCookiesNotFound + }, + hasCredentials: true, + wantLoginCalls: 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var loginCalls atomic.Int32 + resolver := CookieLoginResolver(CookieLoginSpec{ + TrackerID: "FF", + BaseURL: "https://tracker.invalid", + Load: test.load, + Validate: func(context.Context, string, []*http.Cookie) error { + return test.validationErr + }, + HasCredential: func(config.TrackerConfig) bool { return test.hasCredentials }, + Login: func(context.Context, config.TrackerConfig, string, string) error { + loginCalls.Add(1) + return nil + }, + }) + err := resolver(context.Background(), config.TrackerConfig{}, "test.db", api.TrackerAuthLoginRequest{}) + if test.wantValidationErr && !errors.Is(err, transientErr) { + t.Fatalf("resolver error = %v", err) + } + var authRequired *trackerauth.AuthRequiredError + if test.wantAuthRequired != errors.As(err, &authRequired) { + t.Fatalf("auth-required error = %v", err) + } + if !test.wantValidationErr && !test.wantAuthRequired && err != nil { + t.Fatalf("resolver error = %v", err) + } + if loginCalls.Load() != test.wantLoginCalls { + t.Fatalf("login calls = %d, want %d", loginCalls.Load(), test.wantLoginCalls) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/internal/jsondupe/list.go b/internal/trackers/impl/standalone/internal/jsondupe/list.go new file mode 100644 index 00000000..19bedd90 --- /dev/null +++ b/internal/trackers/impl/standalone/internal/jsondupe/list.go @@ -0,0 +1,110 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +// Package jsondupe executes the authenticated JSON-list search protocol shared +// by standalone trackers while leaving query and result identity tracker-local. +package jsondupe + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +const maxResponseBytes = 4 << 20 + +// ListSpec describes one JSON-array duplicate-search request and projection. +type ListSpec struct { + Endpoint string + Query url.Values + Headers http.Header + IDField string + NameField string + SizeField string + Link func(string) string + FailureMessage string +} + +// Search executes a JSON-list duplicate search with number-preserving decoding. +// It rejects non-2xx responses and bodies above 4 MiB through typed adapter +// failure results rather than returning Go errors directly. +func Search(ctx context.Context, client *http.Client, spec ListSpec) dupe.AdapterResult { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, spec.Endpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, spec.FailureMessage, err) + } + req.URL.RawQuery = spec.Query.Encode() + for name, values := range spec.Headers { + for _, value := range values { + req.Header.Add(name, value) + } + } + + resp, err := client.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, spec.FailureMessage, err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, spec.FailureMessage, nil) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, spec.FailureMessage, err) + } + if len(body) > maxResponseBytes { + return dupe.Failed(dupe.FailureResponseParse, spec.FailureMessage, fmt.Errorf("duplicate response exceeds %d bytes", maxResponseBytes)) + } + var items []map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&items); err != nil { + return dupe.Failed(dupe.FailureResponseParse, spec.FailureMessage, err) + } + + entries := make([]api.DupeEntry, 0, len(items)) + for _, item := range items { + id := scalarString(item[spec.IDField]) + entry := api.DupeEntry{Name: scalarString(item[spec.NameField]), ID: id} + if spec.Link != nil { + entry.Link = spec.Link(id) + } + if size := scalarInt64(item[spec.SizeField]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +func scalarString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + default: + return "" + } +} + +func scalarInt64(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/internal/jsondupe/list_test.go b/internal/trackers/impl/standalone/internal/jsondupe/list_test.go new file mode 100644 index 00000000..c2054619 --- /dev/null +++ b/internal/trackers/impl/standalone/internal/jsondupe/list_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jsondupe + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/autobrr/upbrr/internal/trackers/dupe" +) + +func TestSearchProjectsAuthenticatedJSONList(t *testing.T) { + requestErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("imdb") != "1234567" || r.Header.Get("Authorization") != "secret" { + requestErr <- errors.New("unexpected JSON duplicate request shape") + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":9007199254740993,"name":" Example Release ","size":1234}]`)) + })) + defer server.Close() + + result := Search(context.Background(), server.Client(), ListSpec{ + Endpoint: server.URL, + Query: url.Values{ + "imdb": {"1234567"}, + }, + Headers: http.Header{"Authorization": {"secret"}}, + IDField: "id", + NameField: "name", + SizeField: "size", + Link: func(id string) string { return server.URL + "/" + id }, + FailureMessage: "search failed", + }) + select { + case err := <-requestErr: + t.Fatal(err) + default: + } + if result.Disposition() != dupe.DispositionResolved { + t.Fatalf("expected resolved result, got %v: %v", result.Disposition(), result.Cause()) + } + entries := result.Entries() + if len(entries) != 1 || entries[0].ID != "9007199254740993" || entries[0].Name != "Example Release" { + t.Fatalf("unexpected entries: %#v", entries) + } + if !entries[0].SizeKnown || entries[0].SizeBytes != 1234 { + t.Fatalf("expected known size, got %#v", entries[0]) + } +} + +func TestSearchClassifiesStatusAndParseFailures(t *testing.T) { + tests := []struct { + name string + code int + body string + want string + }{ + { + name: "status", + code: http.StatusUnauthorized, + body: `{}`, + want: dupe.FailureResponseStatus, + }, + { + name: "parse", + code: http.StatusOK, + body: `{}`, + want: dupe.FailureResponseParse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.code) + _, _ = w.Write([]byte(test.body)) + })) + defer server.Close() + + result := Search(context.Background(), server.Client(), ListSpec{Endpoint: server.URL, FailureMessage: "safe failure"}) + if result.Disposition() != dupe.DispositionFailed || result.Code() != test.want || result.SafeMessage() != "safe failure" { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } + }) + } +} diff --git a/internal/trackers/impl/standalone/is/bbcode.go b/internal/trackers/impl/standalone/is/bbcode.go new file mode 100644 index 00000000..f4b68a2a --- /dev/null +++ b/internal/trackers/impl/standalone/is/bbcode.go @@ -0,0 +1,14 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + return bbcode.RemoveExtraLines(strings.TrimSpace(bbcode.NormalizeNewlines(value))) +} diff --git a/internal/trackers/impl/standalone/is/definition.go b/internal/trackers/impl/standalone/is/definition.go new file mode 100644 index 00000000..fdce9acc --- /dev/null +++ b/internal/trackers/impl/standalone/is/definition.go @@ -0,0 +1,25 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "is", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/is/description_assets_test.go b/internal/trackers/impl/standalone/is/description_assets_test.go new file mode 100644 index 00000000..90275bde --- /dev/null +++ b/internal/trackers/impl/standalone/is/description_assets_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, + Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "IS", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots:", "normal.png") + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "IS", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/is/dupe.go b/internal/trackers/impl/standalone/is/dupe.go new file mode 100644 index 00000000..6fc053c5 --- /dev/null +++ b/internal/trackers/impl/standalone/is/dupe.go @@ -0,0 +1,206 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "context" + "fmt" + "math" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +var isSizePattern = regexp.MustCompile(`(?i)([0-9]+(?:\.[0-9]+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB|B)\b`) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + baseURL := isBaseURL(s.cfg) + parsedBase, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "IS search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "IS", parsedBase.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid IS cookies", nil) + } + params := url.Values{"do": {"search"}} + category := strings.ToUpper(strings.TrimSpace(string(meta.Identity.Category))) + if category == "MOVIE" { + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for IS movie dupe search", nil) + } + params.Set("search_type", "t_genre") + params.Set("keywords", fmt.Sprintf("tt%07d", meta.Identity.IMDBID)) + } else { + query := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName) + " " + isSeasonEpisode(meta)) + params.Set("search_type", "t_name") + params.Set("keywords", query) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/browse.php", nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "IS search failed", err) + } + req.URL.RawQuery = params.Encode() + req.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(req, trackerCookies) + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "IS search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "IS search failed", nil) + } + root, err := xhtml.Parse(resp.Body) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "IS search failed", err) + } + table := isFirst(root, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "table" && isAttr(node, "id") == "sortabletable" + }) + if table == nil { + return dupe.Resolved(nil, nil) + } + entries := make([]api.DupeEntry, 0) + for _, row := range isFind(table, func(node *xhtml.Node) bool { return node.Type == xhtml.ElementNode && node.Data == "tr" }) { + link := isFirst(row, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(isAttr(node, "href"), "details.php?id=") + }) + if link == nil { + continue + } + entry := api.DupeEntry{Name: strings.TrimSpace(isText(link)), Link: isAbsoluteURL(baseURL, isAttr(link, "href"))} + cells := isFind(row, func(node *xhtml.Node) bool { return node.Type == xhtml.ElementNode && node.Data == "td" }) + if len(cells) >= 5 { + if size, ok := isParseSize(isText(cells[4])); ok { + entry.SizeKnown, entry.SizeBytes = true, size + } + } + if entry.Name != "" { + entries = append(entries, entry) + } + } + return dupe.Resolved(entries, nil) +} + +func isBaseURL(_ config.Config) string { + return "https://immortalseed.me" +} +func isSeasonEpisode(meta api.DuplicateSubject) string { + if meta.SeasonInt > 0 && meta.EpisodeInt > 0 { + return fmt.Sprintf("S%02dE%02d", meta.SeasonInt, meta.EpisodeInt) + } + return strings.TrimSpace(meta.SeasonStr + meta.EpisodeStr) +} +func isFind(root *xhtml.Node, match func(*xhtml.Node) bool) []*xhtml.Node { + result := make([]*xhtml.Node, 0) + var walk func(*xhtml.Node) + walk = func(node *xhtml.Node) { + if node == nil { + return + } + if match(node) { + result = append(result, node) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(root) + return result +} +func isFirst(root *xhtml.Node, match func(*xhtml.Node) bool) *xhtml.Node { + var found *xhtml.Node + var walk func(*xhtml.Node) + walk = func(node *xhtml.Node) { + if node == nil || found != nil { + return + } + if match(node) { + found = node + return + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(root) + return found +} +func isAttr(node *xhtml.Node, key string) string { + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, key) { + return strings.TrimSpace(attr.Val) + } + } + return "" +} +func isText(node *xhtml.Node) string { + if node == nil { + return "" + } + if node.Type == xhtml.TextNode { + return node.Data + } + var builder strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + builder.WriteString(isText(child)) + } + return builder.String() +} +func isAbsoluteURL(baseURL, value string) string { + base, baseErr := url.Parse(baseURL) + ref, refErr := url.Parse(value) + if baseErr == nil && refErr == nil { + return base.ResolveReference(ref).String() + } + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(value, "/") +} +func isParseSize(value string) (int64, bool) { + match := isSizePattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) != 3 { + return 0, false + } + amount, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, false + } + multipliers := map[string]float64{ + "b": 1, + "kb": 1e3, + "mb": 1e6, + "gb": 1e9, + "tb": 1e12, + "kib": 1024, + "mib": 1024 * 1024, + "gib": 1024 * 1024 * 1024, + "tib": 1024 * 1024 * 1024 * 1024, + } + return int64(math.Round(amount * multipliers[strings.ToLower(match[2])])), true +} diff --git a/internal/trackers/impl/standalone/is/profile.go b/internal/trackers/impl/standalone/is/profile.go new file mode 100644 index 00000000..3c444317 --- /dev/null +++ b/internal/trackers/impl/standalone/is/profile.go @@ -0,0 +1,28 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns IS identity, preparation, dupe, auth, and artifact behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "IS", + BaseURL: baseURL, + DescriptionGroup: "is", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + AuthCapability: standalone.CookieAuthCapability("IS"), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{baseURL}}, + } +} + +// New returns a fresh IS definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/is/upload.go b/internal/trackers/impl/standalone/is/upload.go new file mode 100644 index 00000000..ad1ba2d5 --- /dev/null +++ b/internal/trackers/impl/standalone/is/upload.go @@ -0,0 +1,473 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package is + +import ( + "bytes" + "context" + "fmt" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/httpclient" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://immortalseed.me" + uploadURL = baseURL + "/upload.php" + torrentURL = baseURL + "/details.php?hash=" + sourceFlag = "https://immortalseed.me" +) + +var ( + sslPattern = regexp.MustCompile(`details\.php\?hash=([a-zA-Z0-9]+)|download\.php\?id=([a-zA-Z0-9]+)`) + successTexts = []string{"Download Torrent (SSL)", "Thank you for uploading"} +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + nfo *commonhttp.FileField + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: IS %s", state.blockedReason) + } + + files := []commonhttp.FileField{{ + FieldName: "torrentfile", + FileName: metautil.FirstNonEmptyTrimmed(state.releaseName, filepath.Base(state.torrentPath)), + Path: state.torrentPath, + }} + if state.nfo != nil { + files = append(files, *state.nfo) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "IS") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: IS request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + + resp, err := httpclient.New(httpclient.DefaultTimeout).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: IS upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode >= 200 && resp.StatusCode < 400, + commonhttp.DefaultResponsePreviewBytes, + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: IS read upload response: %w", err) + } + id, success := successfulUploadResponse(finalURL, string(responseBody)) + if resp.StatusCode >= 200 && resp.StatusCode < 400 && success { + if id != "" { + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "IS", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: tURL, + TorrentPath: artifactPath, + }}, + }, nil + } + return api.UploadSummary{Uploaded: 1}, nil + } + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "IS", "upload_failure", responsePreview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("IS", resp.StatusCode, responsePreview) +} + +func successfulUploadResponse(finalURL string, responseBody string) (string, bool) { + match := sslPattern.FindStringSubmatch(finalURL + "\n" + responseBody) + if len(match) >= 3 { + if id := metautil.FirstNonEmptyTrimmed(match[1], match[2]); id != "" { + return id, true + } + } + for _, text := range successTexts { + if strings.Contains(responseBody, text) { + return "", true + } + } + return "", false +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "IS", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "is", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "torrentfile", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, []*http.Cookie, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + cookies, err := loadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, err + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + fields := map[string]string{ + "UseNFOasDescr": "no", + "message": buildMessage(req.Meta), + "category": strconv.Itoa(resolveCategoryID(req.Meta)), + "subject": resolveSubject(req.Meta), + "nothingtopost": "1", + "t_image_url": resolvePoster(req.Meta), + "submit": "Upload Torrent", + "anonymous": yesNo(req.TrackerConfig.Anon), + } + if strings.EqualFold(categoryOf(req.Meta), "MOVIE") { + fields["t_link"] = resolveIMDbURL(req.Meta) + } + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: fields["subject"], + fields: fields, + } + if strings.TrimSpace(fields["t_image_url"]) == "" { + state.blockedReason = "missing poster URL" + } + if file, ok := resolveNFO(req.Meta); ok { + state.nfo = &file + } + return state, cookies, nil +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "IS", "immortalseed.me") + if err != nil { + return values, fmt.Errorf("trackers: IS load cookies: %w", err) + } + return values, nil +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + parts := make([]string, 0, 8) + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "Title: "+strings.TrimSpace(meta.EpisodeTitle), "Overview: "+strings.TrimSpace(meta.EpisodeOverview)) + } + if media := trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta); media != "" { + parts = append(parts, media) + } + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + if len(assets.MenuImages) > 0 { + var menuLines []string + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + menuLines = append(menuLines, header) + } + for _, image := range assets.MenuImages { + if strings.TrimSpace(image.RawURL) != "" { + menuLines = append(menuLines, image.RawURL) + } + } + if len(menuLines) > 0 { + parts = append(parts, strings.Join(menuLines, "\n")) + } + } + if len(assets.Screenshots) > 0 { + var shotLines []string + for _, image := range assets.Screenshots { + if strings.TrimSpace(image.RawURL) != "" { + shotLines = append(shotLines, image.RawURL) + } + } + if len(shotLines) > 0 { + parts = append(parts, "Screenshots:\n"+strings.Join(shotLines, "\n")) + } + } + return finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) +} + +func buildMessage(meta api.UploadSubject) string { + message := strings.TrimSpace(resolveOverview(meta)) + if trailer := resolveYouTube(meta); trailer != "" { + if message != "" { + message += "\n\n" + } + message += "[youtube]" + trailer + "[/youtube]" + } + return message +} + +func resolveCategoryID(meta api.UploadSubject) int { + category := strings.ToUpper(strings.TrimSpace(categoryOf(meta))) + resolution := strings.TrimSpace(meta.Release.Resolution) + genres := strings.ToLower(resolveGenres(meta) + " " + resolveKeywords(meta)) + nonEnglish := !hasEnglishAudio(meta) + switch category { + case "TV": + if strings.Contains(genres, "documentary") { + if isSD(meta) { + return 13 + } + return 15 + } + if meta.Anime { + return 6 + } + if strings.Contains(genres, "children") || strings.Contains(genres, "cartoon") { + return 5 + } + if meta.TVPack { + if resolution == "2160p" { + return 63 + } + if isSD(meta) { + return 6 + } + return 4 + } + if resolution == "2160p" { + return 64 + } + if resolution == "1080p" || resolution == "1080i" || resolution == "720p" { + return 8 + } + if isSD(meta) { + if strings.Contains(strings.ToLower(meta.VideoEncode), "xvid") { + return 9 + } + return 48 + } + return 47 + default: + if strings.Contains(genres, "documentary") { + if isSD(meta) { + return 13 + } + return 15 + } + if meta.Anime { + return 6 + } + if resolution == "2160p" { + if nonEnglish { + return 60 + } + return 62 + } + if !isSD(meta) { + if nonEnglish { + return 18 + } + return 16 + } + if isSD(meta) { + if nonEnglish { + return 33 + } + return 14 + } + if nonEnglish { + return 34 + } + return 17 + } +} + +func hasEnglishAudio(meta api.UploadSubject) bool { + for _, language := range meta.AudioLanguages { + lower := strings.ToLower(strings.TrimSpace(language)) + if lower == "english" || lower == "en" { + return true + } + } + return false +} + +func resolveSubject(meta api.UploadSubject) string { + if meta.Scene && strings.TrimSpace(meta.SceneName) != "" { + return strings.TrimSpace(meta.SceneName) + } + name := strings.TrimSpace(meta.ReleaseName) + name = strings.ReplaceAll(name, strings.TrimSpace(meta.Release.Alt), "") + name = strings.ReplaceAll(name, "Dubbed", "") + name = strings.ReplaceAll(name, "Dual-Audio", "") + name = strings.Join(strings.Fields(name), ".") + return strings.Trim(name, ".") +} + +func resolvePoster(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster) + case meta.ProviderMetadata.IMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + case meta.ProviderMetadata.TVmaze != nil: + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Poster) + default: + return "" + } +} + +func resolveIMDbURL(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL) + } + if meta.Identity.IMDBID > 0 { + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) + } + return "" +} + +func resolveOverview(meta api.UploadSubject) string { + switch { + case meta.ProviderMetadata.TMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview) + case meta.ProviderMetadata.IMDB != nil: + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot) + case meta.ProviderMetadata.TVmaze != nil: + return strings.TrimSpace(meta.ProviderMetadata.TVmaze.Summary) + default: + return strings.TrimSpace(meta.EpisodeOverview) + } +} + +func resolveYouTube(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + return "" +} + +func resolveGenres(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) + } + return strings.TrimSpace(meta.Release.Genre) +} + +func resolveKeywords(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) + } + return "" +} + +func resolveNFO(meta api.UploadSubject) (commonhttp.FileField, bool) { + dir := filepath.Dir(metautil.FirstNonEmptyTrimmed(meta.MediaInfoTextPath, meta.SourcePath)) + payload, path, err := commonhttp.ReadFirstMatching(dir, "*.nfo") + if err != nil { + return commonhttp.FileField{}, false + } + return commonhttp.FileField{ + FieldName: "nfofile", + FileName: filepath.Base(path), + Content: payload, + }, true +} + +func isSD(meta api.UploadSubject) bool { + resolution := strings.TrimSpace(meta.Release.Resolution) + return resolution == "" || resolution == "480p" || resolution == "576p" +} + +func categoryOf(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return string(category) +} + +func yesNo(value bool) string { + if value { + return "yes" + } + return "no" +} diff --git a/internal/trackers/impl/is/upload_test.go b/internal/trackers/impl/standalone/is/upload_test.go similarity index 100% rename from internal/trackers/impl/is/upload_test.go rename to internal/trackers/impl/standalone/is/upload_test.go diff --git a/internal/trackers/impl/standalone/mtv/banned_groups.go b/internal/trackers/impl/standalone/mtv/banned_groups.go new file mode 100644 index 00000000..5018ff96 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/banned_groups.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +// bannedGroups returns MTV's static release-group blacklist. +func bannedGroups() []string { + return []string{ + "3LTON", + "[Oj]", + "aXXo", + "BDP", + "BRrip", + "CM8", + "CrEwSaDe", + "CMCT", + "DeadFish", + "DNL", + "ELiTE", + "AFG", + "ZMNT", + "FaNGDiNG0", + "FRDS", + "FUM", + "h65", + "HD2DVD", + "HDTime", + "ION10", + "iPlanet", + "JIVE", + "KiNGDOM", + "LAMA", + "Leffe", + "LOAD", + "mHD", + "mRS", + "mSD", + "NhaNc3", + "nHD", + "nikt0", + "nSD", + "PandaRG", + "PRODJi", + "QxR", + "RARBG", + "RDN", + "SANTi", + "STUTTERSHIT", + "TERMiNAL", + "TM", + "ViSiON", + "WAF", + "x0r", + "XS", + "YIFY", + "ZKBL", + "ZmN", + } +} diff --git a/internal/trackers/impl/standalone/mtv/definition.go b/internal/trackers/impl/standalone/mtv/definition.go new file mode 100644 index 00000000..1c4d4443 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/definition.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + var err error + assets := trackers.DescriptionAssets{} + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) + } + if req.Logger != nil { + req.Logger.Warnf("trackers: MTV description assets failed: %v", err) + } + assets = trackers.DescriptionAssets{} + } + } + + description := strings.TrimSpace(assets.Description) + if !assets.Final { + description, err = BuildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets.Description, assets.Screenshots) + if err != nil { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: MTV description build: %w", err) + } + } + + if strings.TrimSpace(description) == "" && req.Logger != nil { + req.Logger.Infof("trackers: MTV preparation description empty") + } + + return trackers.DescriptionResult{ + Group: "mtv", + Description: description, + }, nil +} diff --git a/internal/trackers/impl/standalone/mtv/definition_test.go b/internal/trackers/impl/standalone/mtv/definition_test.go new file mode 100644 index 00000000..ffe2c452 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/definition_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDefinitionBuildDescriptionUsesMTVGroup(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "MTV", + Meta: api.UploadSubject{}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Group != "mtv" { + t.Fatalf("expected mtv group, got %q", result.Group) + } +} + +func TestDefinitionUploadMissingCookieFile(t *testing.T) { + _, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "MTV", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(t.TempDir(), "ua.db")}}), + Logger: api.NopLogger{}, + }, mtvBaseURL) + if err == nil { + t.Fatal("expected missing cookie file error") + } +} + +func TestDefinitionUploadSuccess(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "ua.db") + cookieDir := filepath.Join(tmp, "cookies") + if err := os.MkdirAll(cookieDir, 0o755); err != nil { + t.Fatalf("mkdir cookie dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cookieDir, "MTV.json"), []byte(`{"session":"cookievalue"}`), 0o600); err != nil { + t.Fatalf("write cookie file: %v", err) + } + torrentPath := filepath.Join(tmp, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.php": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("...authkey=1234567890abcdef1234567890abcdef...")) + case "/upload.php": + if err := r.ParseMultipartForm(5 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + if r.FormValue("auth") == "" { + t.Error("expected auth field") + return + } + http.Redirect(w, r, "/torrents.php?id=55", http.StatusFound) + case "/torrents.php": + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + result, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "MTV", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Type: "WEBDL", + VideoCodec: "HEVC", + ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", + ServiceLongName: "Netflix", + }, + TrackerConfig: config.TrackerConfig{}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + }, server.URL) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected uploaded=1, got %d", result.Uploaded) + } +} + +func TestDefinitionUploadLoginBootstrapSuccess(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "ua.db") + torrentPath := filepath.Join(tmp, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + return + } + if err := r.ParseForm(); err != nil { + t.Errorf("parse login form: %v", err) + return + } + if r.FormValue("username") != "user" || r.FormValue("password") != "pass" { + t.Error("unexpected login credentials") + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "cookievalue", + Path: "/", + }) + http.Redirect(w, r, "/index.php", http.StatusFound) + case "/index.php": + if _, err := r.Cookie("session"); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("...authkey=abcdefabcdefabcdefabcdefabcdefab...")) + case "/upload.php": + if err := r.ParseMultipartForm(5 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + if r.FormValue("auth") == "" { + t.Error("expected auth field") + return + } + http.Redirect(w, r, "/torrents.php?id=99", http.StatusFound) + case "/torrents.php": + if _, err := url.ParseRequestURI(r.RequestURI); err != nil { + t.Errorf("invalid request uri: %v", err) + return + } + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + result, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "MTV", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Type: "WEBDL", + VideoCodec: "HEVC", + ReleaseName: "My.Release.2026.2160p.WEBDL.HEVC", + ServiceLongName: "Netflix", + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Password: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + }, server.URL) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected uploaded=1, got %d", result.Uploaded) + } + if _, err := os.Stat(filepath.Join(tmp, "cookies", "MTV.json")); !os.IsNotExist(err) { + if err == nil { + t.Fatalf("expected no legacy MTV cookie file after login bootstrap; file exists") + } + t.Fatalf("expected no legacy MTV cookie file after login bootstrap; unexpected stat error: %v", err) + } +} diff --git a/internal/trackers/impl/standalone/mtv/description.go b/internal/trackers/impl/standalone/mtv/description.go new file mode 100644 index 00000000..2f05f2fc --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/description.go @@ -0,0 +1,198 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/config" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +var mtvQuoteTagPattern = regexp.MustCompile(`(?i)\[/?quote\]`) +var mtvCollapseLinesPattern = regexp.MustCompile(`\n{3,}`) + +var mtvNFOBlockPattern = regexp.MustCompile( + `(?is)(?:\[(?:center|align=center)\]\s*)?\[(?:spoiler|hide)=(?:Scene|FraMeSToR) NFO:\](?:\[(?:code|pre)\])?.*?(?:\[/(?:code|pre)\])?\[/(?:spoiler|hide)\](?:\s*\[/(?:center|align)\])?`, +) +var mtvURLImagePattern = regexp.MustCompile(`(?is)\[url=[^\]]+\]\s*\[img(?:[^\]]*)?\][^\[]+\[/img\]\s*\[/url\]`) +var mtvImagePattern = regexp.MustCompile(`(?is)\[img(?:[^\]]*)?\][^\[]+\[/img\]`) + +var mtvSignaturePattern = regexp.MustCompile( + `(?is)\[(?:right|align=right)\]\s*\[url=https://github\.com/(?:Audionut|autobrr)/upbrr\].*?\[/url\]\s*\[/(?:right|align)\]`, +) +var mtvEmptyAlignPattern = regexp.MustCompile(`(?is)\[(?:center|right|left|align=(?:center|right|left))\]\s*\[/(?:center|right|left|align)\]`) + +// BuildDescription assembles MTV's media details, eligible tone-mapping header, +// screenshots, and sanitized kept notes. Missing media files are ignored, while +// other read failures and context cancellation are returned. +func BuildDescription( + ctx context.Context, + meta api.UploadSubject, + appConfig config.Config, + keptDescription string, + screenshots []api.ScreenshotImage, +) (string, error) { + select { + case <-ctx.Done(): + return "", fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + parts := make([]string, 0, 8) + + mediaBlock, err := buildMediaInfoBlock(meta, appConfig.MainSettings.DBPath) + if err != nil { + return "", err + } + if mediaBlock != "" { + parts = append(parts, mediaBlock) + } + + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") && strings.TrimSpace(meta.DVDVOBMediaInfoText) != "" { + parts = append(parts, "[mediainfo]"+strings.TrimSpace(meta.DVDVOBMediaInfoText)+"[/mediainfo]") + } + + if shouldIncludeTonemappedHeader(meta, appConfig, screenshots) { + header := strings.TrimSpace(appConfig.Description.TonemappedHeader) + if header != "" { + parts = append(parts, header) + } + } + + if section := buildScreenshotSection(screenshots); section != "" { + parts = append(parts, section) + } + + base := sanitizeNotes(keptDescription) + if base != "" { + parts = append(parts, "[spoiler=Notes]"+base+"[/spoiler]") + } + + return normalize(strings.Join(parts, "\n\n")), nil +} + +func buildMediaInfoBlock(meta api.UploadSubject, dbPath string) (string, error) { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + bdInfoPath, err := resolveBDInfoPath(meta, dbPath) + if err != nil { + return "", err + } + if strings.TrimSpace(bdInfoPath) != "" { + text, err := os.ReadFile(bdInfoPath) + if err != nil { + if !os.IsNotExist(err) { + return "", fmt.Errorf("description: MTV read BDInfo file: %w", err) + } + } else { + trimmed := strings.TrimSpace(string(text)) + if trimmed != "" { + return "[mediainfo]" + trimmed + "[/mediainfo]", nil + } + } + } + } + + if strings.TrimSpace(meta.MediaInfoTextPath) != "" { + text, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + if !os.IsNotExist(err) { + return "", fmt.Errorf("description: MTV read MediaInfo file: %w", err) + } + } else { + trimmed := strings.TrimSpace(string(text)) + if trimmed != "" { + return "[mediainfo]" + trimmed + "[/mediainfo]", nil + } + } + } + + return "", nil +} + +func resolveBDInfoPath(meta api.UploadSubject, dbPath string) (string, error) { + if strings.TrimSpace(dbPath) == "" { + return "", nil + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("description: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("description: %w", err) + } + path := paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists)) + if strings.TrimSpace(path) == "" { + return "", nil + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("description: MTV stat BDInfo summary: %w", err) + } + return path, nil +} + +func buildScreenshotSection(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := make([]string, 0, len(images)) + for _, image := range images { + imgURL := strings.TrimSpace(image.ImgURL) + if imgURL == "" { + imgURL = strings.TrimSpace(image.RawURL) + } + rawURL := strings.TrimSpace(image.RawURL) + if rawURL == "" { + rawURL = imgURL + } + if imgURL == "" { + continue + } + if rawURL != "" { + parts = append(parts, "[url="+rawURL+"][img=250]"+imgURL+"[/img][/url]") + continue + } + parts = append(parts, "[img=250]"+imgURL+"[/img]") + } + return strings.Join(parts, "") +} + +func normalize(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + return strings.TrimSpace(mtvCollapseLinesPattern.ReplaceAllString(trimmed, "\n\n")) +} + +func sanitizeNotes(value string) string { + cleaned := mtvQuoteTagPattern.ReplaceAllString(value, "") + cleaned = mtvNFOBlockPattern.ReplaceAllString(cleaned, "") + cleaned = mtvURLImagePattern.ReplaceAllString(cleaned, "") + cleaned = mtvImagePattern.ReplaceAllString(cleaned, "") + cleaned = mtvSignaturePattern.ReplaceAllString(cleaned, "") + cleaned = mtvEmptyAlignPattern.ReplaceAllString(cleaned, "") + return normalize(cleaned) +} + +func shouldIncludeTonemappedHeader(meta api.UploadSubject, appConfig config.Config, screenshots []api.ScreenshotImage) bool { + if !appConfig.ScreenshotHandling.ToneMap { + return false + } + if len(screenshots) == 0 { + return false + } + hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) + return strings.Contains(hdr, "HDR") || strings.Contains(hdr, "DV") +} diff --git a/internal/trackers/impl/standalone/mtv/description_test.go b/internal/trackers/impl/standalone/mtv/description_test.go new file mode 100644 index 00000000..6512f6b1 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/description_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesMediaInfoAndNotes(t *testing.T) { + dir := t.TempDir() + miPath := filepath.Join(dir, "MEDIAINFO.txt") + if err := os.WriteFile(miPath, []byte("MI_CONTENT"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + meta := api.UploadSubject{ + MediaInfoTextPath: miPath, + } + + desc, err := BuildDescription(context.Background(), meta, config.Config{}, "[quote]note[/quote]", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(desc, "[mediainfo]MI_CONTENT[/mediainfo]") { + t.Fatalf("expected mediainfo block, got %q", desc) + } + if !strings.Contains(desc, "[spoiler=Notes]note[/spoiler]") { + t.Fatalf("expected notes spoiler, got %q", desc) + } +} + +func TestBuildDescriptionIncludesTonemapAndScreenshots(t *testing.T) { + meta := api.UploadSubject{HDR: "HDR10"} + cfg := config.Config{ + ScreenshotHandling: config.ScreenshotHandlingConfig{ToneMap: true}, + Description: config.DescriptionSettingsConfig{TonemappedHeader: "[center]tone[/center]"}, + } + screens := []api.ScreenshotImage{{RawURL: "https://raw/1", ImgURL: "https://img/1.jpg"}} + + desc, err := BuildDescription(context.Background(), meta, cfg, "", screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(desc, "[center]tone[/center]") { + t.Fatalf("expected tonemap header, got %q", desc) + } + if !strings.Contains(desc, "[url=https://raw/1][img=250]https://img/1.jpg[/img][/url]") { + t.Fatalf("expected mtv screenshot bbcode, got %q", desc) + } +} + +func TestBuildDescriptionSkipsTonemapHeaderForNonHDR(t *testing.T) { + meta := api.UploadSubject{} + cfg := config.Config{ + ScreenshotHandling: config.ScreenshotHandlingConfig{ToneMap: true}, + Description: config.DescriptionSettingsConfig{TonemappedHeader: "[center]tone[/center]"}, + } + screens := []api.ScreenshotImage{{RawURL: "https://raw/1", ImgURL: "https://img/1.jpg"}} + + desc, err := BuildDescription(context.Background(), meta, cfg, "", screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(desc, "[center]tone[/center]") { + t.Fatalf("did not expect tonemap header for non-HDR content, got %q", desc) + } +} + +func TestBuildDescriptionStripsNFOFromNotes(t *testing.T) { + meta := api.UploadSubject{} + kept := "[center][spoiler=Scene NFO:][code]scene nfo[/code][/spoiler][/center]\n\nActual note" + + desc, err := BuildDescription(context.Background(), meta, config.Config{}, kept, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(desc, "scene nfo") { + t.Fatalf("expected nfo removed from notes, got %q", desc) + } + if !strings.Contains(desc, "[spoiler=Notes]Actual note[/spoiler]") { + t.Fatalf("expected cleaned notes spoiler, got %q", desc) + } +} + +func TestBuildDescriptionStripsImagesFromNotesWhenScreenshotsProvided(t *testing.T) { + meta := api.UploadSubject{} + kept := "[url=https://raw/1][img=250]https://img/1.jpg[/img][/url]\n\nSome note" + screens := []api.ScreenshotImage{{RawURL: "https://raw/1", ImgURL: "https://img/1.jpg"}} + + desc, err := BuildDescription(context.Background(), meta, config.Config{}, kept, screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(desc, "https://img/1.jpg") != 1 { + t.Fatalf("expected screenshot included once, got %q", desc) + } + if !strings.Contains(desc, "[spoiler=Notes]Some note[/spoiler]") { + t.Fatalf("expected notes without duplicated screenshot tags, got %q", desc) + } +} + +func TestBuildDescriptionStripsWidthImagesAndSignatureFromNotes(t *testing.T) { + meta := api.UploadSubject{} + kept := `[align=center] +[url=https://pixhost.to/fv71hr.png][img width=350]https://pixhost.to/fv71hr.png[/img][/url] +[/align] + +[align=right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/align]` + screens := []api.ScreenshotImage{{RawURL: "https://pixhost.to/fv71hr.png", ImgURL: "https://pixhost.to/fv71hr.png"}} + + desc, err := BuildDescription(context.Background(), meta, config.Config{}, kept, screens) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(desc, "[spoiler=Notes]") { + t.Fatalf("expected notes section omitted when only screenshots/signature remain, got %q", desc) + } + if strings.Count(desc, "https://pixhost.to/fv71hr.png") != 2 { + t.Fatalf("expected one screenshot block only, got %q", desc) + } + if strings.Contains(desc, "upbrr") { + t.Fatalf("expected default signature removed from notes, got %q", desc) + } +} diff --git a/internal/trackers/impl/standalone/mtv/dupe.go b/internal/trackers/impl/standalone/mtv/dupe.go new file mode 100644 index 00000000..c437435b --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/dupe.go @@ -0,0 +1,211 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "encoding/xml" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +const mtvTorznabEndpoint = "https://www.morethantv.me/api/torznab" + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (h dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := mtvAPIKey(h.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + + params := url.Values{} + params.Set("t", "search") + params.Set("apikey", apiKey) + params.Set("limit", "100") + + switch { + case meta.Identity.IMDBID != 0: + params.Set("imdbid", "tt"+strconv.Itoa(meta.Identity.IMDBID)) + case meta.Identity.TMDBID != 0: + params.Set("tmdbid", strconv.Itoa(meta.Identity.TMDBID)) + case isMTVTVCategory(meta) && meta.Identity.TVDBID != 0: + params.Set("tvdbid", strconv.Itoa(meta.Identity.TVDBID)) + default: + query := cleanMTVSearchTitle(meta) + if query == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb/tmdb/tvdb id or title for MTV dupe search", nil) + } + params.Set("q", query) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mtvTorznabEndpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "MTV search failed", fmt.Errorf("build MTV request: %w", err)) + } + req.URL.RawQuery = params.Encode() + + resp, err := h.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "MTV search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return dupe.Failed(dupe.FailureResponseStatus, "MTV search failed", nil) + } + + var payload mtvRSS + if err := xml.NewDecoder(resp.Body).Decode(&payload); err != nil { + return dupe.Failed(dupe.FailureResponseParse, "MTV response parse failed", err) + } + + entries := make([]api.DupeEntry, 0, len(payload.Channel.Items)) + for _, item := range payload.Channel.Items { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + + fileCount := parsePositiveInt(item.Files) + sizeBytes := parsePositiveInt64(item.Size) + for _, attr := range item.allAttrs() { + name := strings.ToLower(strings.TrimSpace(attr.Name)) + value := strings.TrimSpace(attr.Value) + switch { + case fileCount == 0 && (name == "files" || name == "file_count" || name == "filecount"): + fileCount = parsePositiveInt(value) + case sizeBytes == 0 && name == "size": + sizeBytes = parsePositiveInt64(value) + } + } + + guid := strings.TrimSpace(item.GUID) + download := strings.TrimSpace(item.Link) + if download == "" { + download = strings.TrimSpace(item.Enclosure.URL) + } + + entry := api.DupeEntry{ + Name: title, + Files: []string{title}, + FileCount: fileCount, + ID: guid, + Link: guid, + Download: strings.ReplaceAll(download, "&", "&"), + } + if sizeBytes > 0 { + entry.SizeKnown = true + entry.SizeBytes = sizeBytes + } + entries = append(entries, entry) + } + + return dupe.Resolved(entries, nil) +} + +// isMTVTVCategory reports whether MTV torznab searches may use a TVDB ID query. +// Canonical movie identity suppresses TVDB queries. +func isMTVTVCategory(meta api.DuplicateSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func cleanMTVSearchTitle(meta api.DuplicateSubject) string { + query := strings.TrimSpace(meta.Release.Title) + if query == "" { + query = strings.TrimSpace(meta.ReleaseName) + } + if query == "" { + return "" + } + query = strings.ReplaceAll(query, ": ", " ") + query = strings.ReplaceAll(query, "’", "") + query = strings.ReplaceAll(query, "'", "") + return strings.Join(strings.Fields(query), " ") +} + +func parsePositiveInt(value string) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +func parsePositiveInt64(value string) int64 { + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +type mtvRSS struct { + Channel mtvChannel `xml:"channel"` +} + +type mtvChannel struct { + Items []mtvItem `xml:"item"` +} + +type mtvItem struct { + Title string `xml:"title"` + Files string `xml:"files"` + Size string `xml:"size"` + GUID string `xml:"guid"` + Link string `xml:"link"` + Enclosure mtvEnclosure `xml:"enclosure"` + Attrs []mtvAttr `xml:"attr"` + TorznabAttrs []mtvAttr `xml:"http://torznab.com/schemas/2015/feed attr"` +} + +func (i mtvItem) allAttrs() []mtvAttr { + if len(i.Attrs) == 0 { + return i.TorznabAttrs + } + if len(i.TorznabAttrs) == 0 { + return i.Attrs + } + combined := make([]mtvAttr, 0, len(i.Attrs)+len(i.TorznabAttrs)) + combined = append(combined, i.Attrs...) + combined = append(combined, i.TorznabAttrs...) + return combined +} + +type mtvAttr struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +type mtvEnclosure struct { + URL string `xml:"url,attr"` +} + +func mtvAPIKey(cfg config.Config) string { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "MTV") { + return strings.TrimSpace(entry.APIKey) + } + } + return "" +} diff --git a/internal/trackers/impl/standalone/mtv/dupe_test.go b/internal/trackers/impl/standalone/mtv/dupe_test.go new file mode 100644 index 00000000..f64530f1 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/dupe_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +func TestMTVHandlerUsesIMDBPriorityAndParsesXML(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + if got := req.URL.String(); !strings.HasPrefix(got, mtvTorznabEndpoint+"?") { + t.Fatalf("unexpected endpoint %q", got) + } + + query := req.URL.Query() + assertQueryParam(t, query, "t", "search") + assertQueryParam(t, query, "apikey", "token") + assertQueryParam(t, query, "limit", "100") + assertQueryParam(t, query, "imdbid", "tt123456") + if got := query.Get("tmdbid"); got != "" { + t.Fatalf("tmdbid should be empty when imdbid is present, got %q", got) + } + if got := query.Get("tvdbid"); got != "" { + t.Fatalf("tvdbid should be empty when imdbid is present, got %q", got) + } + if got := query.Get("q"); got != "" { + t.Fatalf("q should be empty when imdbid is present, got %q", got) + } + + body := ` + + + + Example.Release.1080p.WEB-DL.DDP5.1.H.264-GRP + 3 + 123456789 + https://www.morethantv.me/torrents.php?id=100 + https://www.morethantv.me/download.php/100?torrent_pass=abc&https=1 + + + Example.Release.2160p.WEB-DL.DDP5.1.HEVC-GRP + https://www.morethantv.me/torrents.php?id=101 + https://www.morethantv.me/download.php/101?torrent_pass=abc&https=1 + + + + +` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "MTV", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "MTV": {APIKey: "token"}, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{ + IMDBID: 123456, + TMDBID: 999, + TVDBID: 888, + }, + Release: api.ReleaseInfo{Title: "Ignored Title"}, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + first := entries[0] + if first.Name != "Example.Release.1080p.WEB-DL.DDP5.1.H.264-GRP" { + t.Fatalf("unexpected first entry name %q", first.Name) + } + if first.FileCount != 3 { + t.Fatalf("expected file_count=3, got %d", first.FileCount) + } + if !first.SizeKnown || first.SizeBytes != 123456789 { + t.Fatalf("expected parsed size for first entry, got known=%t size=%d", first.SizeKnown, first.SizeBytes) + } + if first.ID != "https://www.morethantv.me/torrents.php?id=100" { + t.Fatalf("unexpected first entry ID %q", first.ID) + } + if first.Link != "https://www.morethantv.me/torrents.php?id=100" { + t.Fatalf("unexpected first entry link %q", first.Link) + } + if first.Download != "https://www.morethantv.me/download.php/100?torrent_pass=abc&https=1" { + t.Fatalf("unexpected first entry download %q", first.Download) + } + if len(first.Files) != 1 || first.Files[0] != first.Name { + t.Fatalf("expected first entry files to contain release title, got %#v", first.Files) + } + + second := entries[1] + if second.FileCount != 7 { + t.Fatalf("expected attr-derived file_count=7, got %d", second.FileCount) + } + if !second.SizeKnown || second.SizeBytes != 222333444 { + t.Fatalf("expected attr-derived size, got known=%t size=%d", second.SizeKnown, second.SizeBytes) + } +} + +func TestMTVHandlerFallsBackToCleanedTitleQuery(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assertQueryParam(t, query, "t", "search") + assertQueryParam(t, query, "apikey", "token") + assertQueryParam(t, query, "limit", "100") + assertQueryParam(t, query, "q", "Johns Story The Return") + if got := query.Get("imdbid"); got != "" { + t.Fatalf("imdbid should be empty, got %q", got) + } + body := `` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "MTV", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "MTV": {APIKey: "token"}, + }, + }, + }, client, api.NopLogger{}) + meta := api.DuplicateSubject{Release: api.ReleaseInfo{Title: "John's Story: The Return"}} + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +func TestMTVHandlerSkipsTVDBForMovie(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assertQueryParam(t, query, "q", "Movie Title") + if got := query.Get("tvdbid"); got != "" { + t.Fatalf("tvdbid should be empty for movie category, got %q", got) + } + body := `` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "MTV", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "MTV": {APIKey: "token"}, + }, + }, + }, client, api.NopLogger{}) + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE", TVDBID: 888}, + Release: api.ReleaseInfo{Title: "Movie Title"}, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +func TestMTVHandlerUsesTVDBForTV(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assertQueryParam(t, query, "tvdbid", "888") + if got := query.Get("q"); got != "" { + t.Fatalf("q should be empty when tvdbid is present, got %q", got) + } + body := `` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + } + + handler := dupe.NewAdapter(New(), "MTV", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "MTV": {APIKey: "token"}, + }, + }, + }, client, api.NopLogger{}) + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 888}, + Release: api.ReleaseInfo{Title: "Show Title"}, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func assertQueryParam(t *testing.T, query url.Values, key string, expected string) { + t.Helper() + if got := query.Get(key); got != expected { + t.Fatalf("unexpected %s query value: got %q want %q", key, got, expected) + } +} diff --git a/internal/trackers/impl/standalone/mtv/profile.go b/internal/trackers/impl/standalone/mtv/profile.go new file mode 100644 index 00000000..0bbf8bba --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/profile.go @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns MTV identity, preparation, dupe, auth, bans, and policies. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "MTV", + BaseURL: mtvBaseURL, + DescriptionGroup: "mtv", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: func(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, req, mtvBaseURL) + }, + NewDuplicateAdapter: newDuplicateAdapter, + BannedGroups: bannedGroups(), + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{ + { + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB, trackers.MetadataFieldTVDB}, + Disposition: api.RuleDispositionStrict, + }, + }, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "MTV"}, + AudioPolicy: &trackers.AudioPolicy{BlockEnglishOriginalWithForeign: true}, + ImageHostPolicy: &trackers.ImageHostPolicy{AllowedHosts: []string{"imgbox", "imgbb"}}, + DupePolicy: &trackers.DupePolicy{ContainsFilenameMatch: true, NormalizeMTVName: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"tracker.morethantv"}, SearchPreference: trackers.TorrentSearchPreferenceSmallPieces, + }, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "MTV", + DisplayName: "MTV", + AuthKind: "api_key_cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + SupportsTOTP: true, + SupportsManual2FA: true, + RequiresAPIKey: true, + Notes: []string{"API key covers Torznab/search; cookies/login cover upload authkey."}, + }, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, login, mtvBaseURL) + }, + AuthPolicy: &trackers.AuthPolicy{ + APIKeyRequiresUploadSession: true, + CookieCompletesAPIKeyAuth: true, + UploadSessionMissingMessage: "API key covers Torznab/search; imported cookies or login credentials required for upload auth", + }, + } +} + +// New returns a fresh MTV definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/mtv/upload.go b/internal/trackers/impl/standalone/mtv/upload.go new file mode 100644 index 00000000..64825bf5 --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/upload.go @@ -0,0 +1,884 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + mtvBaseURL = "https://www.morethantv.me" + mtvUploadPath = "/upload.php" + mtvIndexPath = "/index.php" + mtvUserAgentWeb = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" +) + +var mtvTokenPattern = regexp.MustCompile(`name="token"\s+value="([^"]{16,128})"`) + +// ErrSubmitted2FARejected marks an MTV failure after a submitted manual 2FA code +// reached the tracker and was rejected. +var ErrSubmitted2FARejected = trackers.ErrSubmitted2FARejected + +var errMTVAuthKeyNotFound = errors.New("trackers: MTV auth key not found") + +func uploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (api.UploadSummary, error) { + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, baseURL) + }) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: MTV submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (trackers.PreparedOperation, error) { + select { + case <-ctx.Done(): + return trackers.PreparedOperation{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + uploadURL := baseURL + mtvUploadPath + + assets := trackers.DescriptionAssets{} + var err error + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + } + descText := strings.TrimSpace(assets.Description) + if !assets.Final { + descText, err = BuildDescription(ctx, req.Meta, req.Runtime.DescriptionConfig(), assets.Description, assets.Screenshots) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + + torrentPath, err := resolveTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + + previewFields := buildUploadFields(req, "[redacted]", descText) + preview := standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "MTV", + ReadyMessage: "dry-run payload generated (auth placeholder used)", + ReleaseName: resolveUploadName(req.Meta), + DescriptionGroup: "mtv", + Description: descText, + Endpoint: uploadURL, + Payload: previewFields, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: torrentPath, + Present: strings.TrimSpace(torrentPath) != "", + }}, + }) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + + cookies, err := loadMTVCookies(ctx, req.Runtime.DBPath) + if err != nil { + cookies = nil + } + auth, client, err := resolveAuthKey(ctx, baseURL, cookies) + if err != nil { + if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { + if cookies == nil { + return trackers.PreparedOperation{}, errors.New("trackers: MTV cookie invalid/missing and username/password not configured") + } + return trackers.PreparedOperation{}, err + } + var effectiveBaseURL string + auth, client, cookies, effectiveBaseURL, err = loginAndResolveAuthKey(ctx, req.TrackerConfig, baseURL, api.TrackerAuthLoginRequest{}) + if err != nil { + return trackers.PreparedOperation{}, err + } + if strings.TrimSpace(effectiveBaseURL) != "" && !strings.EqualFold(effectiveBaseURL, baseURL) { + baseURL = effectiveBaseURL + uploadURL = baseURL + mtvUploadPath + } + if persistErr := saveMTVCookies(ctx, req.Runtime.DBPath, cookies); persistErr != nil && req.Logger != nil { + req.Logger.Warnf("trackers: MTV failed to persist cookies: %v", persistErr) + } + } + + fields := buildUploadFields(req, auth, descText) + body, contentType, err := buildMultipartPayload(fields, torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, uploadURL, client, body, contentType) + }, nil), nil +} + +func submitPreparedUpload(ctx context.Context, uploadURL string, client *http.Client, body []byte, contentType string) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: MTV request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", mtvUserAgentWeb) + + resp, err := client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: MTV upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + if resp.StatusCode == http.StatusBadRequest { + preview := string(bodyPreview) + if strings.Contains(preview, "Request Header") || strings.Contains(preview, "Cookie Too Large") || strings.Contains(preview, "Header Too Large") { + return api.UploadSummary{}, errors.New("trackers: MTV data error (request header/cookie too large)") + } + } + + if strings.Contains(finalURL, "torrents.php") { + return api.UploadSummary{Uploaded: 1}, nil + } + + return api.UploadSummary{}, commonhttp.UploadHTTPErrorWithURL("MTV", resp.StatusCode, finalURL, bodyPreview) +} + +func resolveAuthKey(ctx context.Context, baseURL string, cookies map[string]string) (string, *http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV create auth cookie jar: %w", err) + } + parsedBase, err := url.Parse(baseURL) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV parse base URL: %w", err) + } + jarCookies := make([]*http.Cookie, 0, len(cookies)) + for name, value := range cookies { + if strings.TrimSpace(name) == "" || strings.TrimSpace(value) == "" { + continue + } + // #nosec G124 -- Outbound tracker jar cookie mirrors configured MTV session values. + jarCookies = append(jarCookies, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Domain: parsedBase.Hostname(), + }) + } + jar.SetCookies(parsedBase, jarCookies) + + client := &http.Client{Timeout: 20 * time.Second, Jar: jar} + indexURL := strings.TrimRight(baseURL, "/") + mtvIndexPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV build auth request: %w", err) + } + req.Header.Set("User-Agent", mtvUserAgentWeb) + + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV auth request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, fmt.Errorf("trackers: MTV auth key lookup failed %s: auth status %d", mtvResponseTrace(resp), resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV read auth response: %w", err) + } + auth := extractMTVAuthKey(string(body)) + if auth == "" { + return "", nil, fmt.Errorf("%w: %s", errMTVAuthKeyNotFound, mtvResponseTrace(resp)) + } + return auth, client, nil +} + +func loadMTVCookies(ctx context.Context, dbPath string) (map[string]string, error) { + values, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "MTV") + if err != nil { + return values, fmt.Errorf("trackers: MTV load cookies: %w", err) + } + return values, nil +} + +func saveMTVCookies(ctx context.Context, dbPath string, values map[string]string) error { + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "MTV", values); err != nil { + return fmt.Errorf("trackers: MTV save cookies: %w", err) + } + return nil +} + +// ResolveSessionForTrackerAuth validates MTV stored cookies or logs in with +// configured credentials. After a successful login, cookie persistence failures +// are returned distinctly from remote authentication failures. +func ResolveSessionForTrackerAuth(ctx context.Context, cfg config.TrackerConfig, dbPath string) error { + return resolveSessionForTrackerAuthAt(ctx, cfg, dbPath, mtvBaseURL) +} + +func resolveSessionForTrackerAuthAt(ctx context.Context, cfg config.TrackerConfig, dbPath string, baseURL string) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, api.TrackerAuthLoginRequest{}, baseURL) +} + +// ResolveSessionForTrackerAuthLogin validates MTV stored cookies or logs in +// with configured credentials. When MTV requires 2FA, login.Code is used before +// falling back to the configured OTP URI; if neither yields a code, the error +// is returned before stored cookies are replaced. A rejected submitted code can +// return [ErrSubmitted2FARejected] with the auth-key lookup failure before +// refreshed cookies are persisted. Response read errors are returned directly +// and are not classified as submitted-code rejections. +func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, login, mtvBaseURL) +} + +func resolveSessionForTrackerAuthLoginAt( + ctx context.Context, + cfg config.TrackerConfig, + dbPath string, + login api.TrackerAuthLoginRequest, + baseURL string, +) error { + cookies, err := loadMTVCookies(ctx, dbPath) + if err == nil && len(cookies) > 0 { + if _, _, err := resolveAuthKey(ctx, baseURL, cookies); err == nil { + return nil + } else if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return err + } + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return errors.New("trackers: MTV cookie invalid/missing and username/password not configured") + } + _, _, values, _, err := loginAndResolveAuthKey(ctx, cfg, baseURL, login) + if err != nil { + return err + } + if len(values) == 0 { + return errors.New("trackers: MTV login returned no usable cookies") + } + if err := saveMTVCookies(ctx, dbPath, values); err != nil { + return fmt.Errorf("trackers: MTV persist cookies after successful login: %w", err) + } + return nil +} + +// loginAndResolveAuthKey performs MTV login, optional TOTP submission, and auth +// key discovery. MTV can return authkey in the final login response, so that +// body is checked before falling back to the index page. Missing form tokens +// and auth-key parser failures remain ordinary errors unless an auth-key miss +// follows a submitted manual code. Authenticated cookies and the effective base +// URL are returned only after auth-key discovery so upload can reuse any +// canonical host reached during login redirects. +func loginAndResolveAuthKey( + ctx context.Context, + cfg config.TrackerConfig, + baseURL string, + login api.TrackerAuthLoginRequest, +) (string, *http.Client, map[string]string, string, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV create login cookie jar: %w", err) + } + client := &http.Client{Timeout: 25 * time.Second, Jar: jar} + + loginURL := strings.TrimRight(baseURL, "/") + "/login" + loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV build login page request: %w", err) + } + loginReq.Header.Set("User-Agent", mtvUserAgentWeb) + loginResp, err := client.Do(loginReq) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV login page request: %w", err) + } + loginBody, err := io.ReadAll(loginResp.Body) + _ = loginResp.Body.Close() + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV read login page response: %w", err) + } + effectiveBaseURL := mtvEffectiveBaseURL(baseURL, loginResp) + effectiveLoginURL := strings.TrimRight(effectiveBaseURL, "/") + "/login" + match := mtvTokenPattern.FindStringSubmatch(string(loginBody)) + if len(match) < 2 { + return "", nil, nil, "", errors.New("trackers: MTV login token not found") + } + token := strings.TrimSpace(match[1]) + + form := url.Values{} + form.Set("username", strings.TrimSpace(cfg.Username)) + form.Set("password", strings.TrimSpace(cfg.Password)) + form.Set("keeploggedin", "1") + form.Set("cinfo", "1920|1080|24|0") + form.Set("submit", "login") + form.Set("iplocked", "1") + form.Set("token", token) + + postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, effectiveLoginURL, strings.NewReader(form.Encode())) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV build login request: %w", err) + } + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.Header.Set("User-Agent", mtvUserAgentWeb) + postResp, err := client.Do(postReq) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV login request: %w", err) + } + body, err := io.ReadAll(postResp.Body) + _ = postResp.Body.Close() + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV read login response: %w", err) + } + finalAuthBody := string(body) + finalAuthTrace := mtvResponseTrace(postResp) + + submittedManualCode := false + if postResp.Request != nil && postResp.Request.URL != nil && strings.Contains(postResp.Request.URL.Path, "/twofactor/login") { + twoFactorTokenMatch := mtvTokenPattern.FindStringSubmatch(string(body)) + if len(twoFactorTokenMatch) < 2 { + return "", nil, nil, "", errors.New("trackers: MTV 2FA token not found") + } + code, err := resolveMTV2FACode(cfg, login) + if err != nil { + return "", nil, nil, "", err + } + twoFactorForm := url.Values{} + twoFactorForm.Set("token", twoFactorTokenMatch[1]) + twoFactorForm.Set("code", code) + twoFactorForm.Set("submit", "login") + twoReq, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + strings.TrimRight(effectiveBaseURL, "/")+"/twofactor/login", + strings.NewReader(twoFactorForm.Encode()), + ) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV build 2FA login request: %w", err) + } + twoReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + twoReq.Header.Set("User-Agent", mtvUserAgentWeb) + twoResp, err := client.Do(twoReq) + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV 2FA login request: %w", err) + } + twoFactorBody, err := io.ReadAll(twoResp.Body) + _ = twoResp.Body.Close() + if err != nil { + return "", nil, nil, "", fmt.Errorf("trackers: MTV read 2FA login response: %w", err) + } + finalAuthBody = string(twoFactorBody) + finalAuthTrace = mtvResponseTrace(twoResp) + submittedManualCode = strings.TrimSpace(login.Code) != "" + } + + if auth := extractMTVAuthKey(finalAuthBody); auth != "" { + cookieMap := cookiesFromJar(effectiveBaseURL, client.Jar) + return auth, client, cookieMap, effectiveBaseURL, nil + } + auth, authedClient, err := resolveAuthKeyFromClient(ctx, effectiveBaseURL, client) + if err != nil { + if submittedManualCode && errors.Is(err, errMTVAuthKeyNotFound) { + return "", nil, nil, "", fmt.Errorf( + "trackers: MTV auth key not found after submitted 2FA final_%s: %w: %w", + finalAuthTrace, + err, + ErrSubmitted2FARejected, + ) + } + if errors.Is(err, errMTVAuthKeyNotFound) { + return "", nil, nil, "", fmt.Errorf("trackers: MTV auth key not found after login final_%s: %w", finalAuthTrace, err) + } + return "", nil, nil, "", fmt.Errorf("trackers: MTV auth key lookup after login final_%s: %w", finalAuthTrace, err) + } + cookieMap := cookiesFromJar(effectiveBaseURL, authedClient.Jar) + return auth, authedClient, cookieMap, effectiveBaseURL, nil +} + +func mtvEffectiveBaseURL(fallback string, resp *http.Response) string { + if resp == nil || resp.Request == nil || resp.Request.URL == nil { + return strings.TrimRight(fallback, "/") + } + u := resp.Request.URL + if strings.TrimSpace(u.Scheme) == "" || strings.TrimSpace(u.Host) == "" { + return strings.TrimRight(fallback, "/") + } + return u.Scheme + "://" + u.Host +} + +func mtvResponseTrace(resp *http.Response) string { + if resp == nil { + return "path=unknown status=0" + } + path := "unknown" + if resp.Request != nil && resp.Request.URL != nil { + path = resp.Request.URL.EscapedPath() + if strings.TrimSpace(path) == "" { + path = "/" + } + } + return fmt.Sprintf("path=%s status=%d", path, resp.StatusCode) +} + +func extractMTVAuthKey(body string) string { + const marker = "authkey=" + idx := strings.LastIndex(body, marker) + if idx < 0 { + return "" + } + raw := strings.TrimSpace(body[idx+len(marker):]) + if len(raw) < 32 { + return "" + } + return raw[:32] +} + +// resolveMTV2FACode prefers a submitted manual code so users can continue a +// browser-visible challenge when no reusable OTP URI is configured. +func resolveMTV2FACode(cfg config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { + if code := strings.TrimSpace(login.Code); code != "" { + return code, nil + } + code, err := totpFromOTPURI(strings.TrimSpace(cfg.OTPURI), time.Now()) + if err != nil { + return "", fmt.Errorf("trackers: MTV 2FA required but otp_uri invalid: %w", err) + } + return code, nil +} + +func resolveAuthKeyFromClient(ctx context.Context, baseURL string, client *http.Client) (string, *http.Client, error) { + indexURL := strings.TrimRight(baseURL, "/") + mtvIndexPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV build auth request: %w", err) + } + req.Header.Set("User-Agent", mtvUserAgentWeb) + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV auth request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, fmt.Errorf("trackers: MTV auth key lookup failed %s: auth status %d", mtvResponseTrace(resp), resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("trackers: MTV read auth response: %w", err) + } + auth := extractMTVAuthKey(string(body)) + if auth == "" { + return "", nil, fmt.Errorf("%w: %s", errMTVAuthKeyNotFound, mtvResponseTrace(resp)) + } + return auth, client, nil +} + +func cookiesFromJar(baseURL string, jar http.CookieJar) map[string]string { + if jar == nil { + return nil + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil + } + out := make(map[string]string) + for _, cookie := range jar.Cookies(parsed) { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" { + continue + } + out[strings.TrimSpace(cookie.Name)] = cookie.Value + } + return out +} + +func totpFromOTPURI(otpURI string, now time.Time) (string, error) { + trimmed := strings.TrimSpace(otpURI) + if trimmed == "" { + return "", errors.New("empty otp_uri") + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("trackers: MTV parse otp_uri: %w", err) + } + query := parsed.Query() + secret := strings.TrimSpace(query.Get("secret")) + if secret == "" { + return "", errors.New("missing secret query parameter") + } + interval := 30 + if period := strings.TrimSpace(query.Get("period")); period != "" { + if value, err := strconv.Atoi(period); err == nil && value > 0 { + interval = value + } + } + counterTime := now.Unix() / int64(interval) + if counterTime < 0 { + return "", errors.New("totp counter before unix epoch") + } + counter := uint64(counterTime) + + decoder := base32.StdEncoding.WithPadding(base32.NoPadding) + secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) + if err != nil { + return "", fmt.Errorf("trackers: MTV decode otp secret: %w", err) + } + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + mac := hmac.New(sha1.New, secretBytes) + _, _ = mac.Write(buf) + hash := mac.Sum(nil) + offset := hash[len(hash)-1] & 0x0f + code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) + return fmt.Sprintf("%06d", code%1000000), nil +} + +func buildUploadFields(req trackers.PreparationInput, auth string, description string) map[string]string { + meta := req.Meta + anon := "1" + if !req.TrackerConfig.Anon { + anon = "0" + } + return map[string]string{ + "image": "", + "title": resolveUploadName(meta), + "category": resolveCategoryID(meta), + "Resolution": resolveResolutionID(meta), + "source": resolveSourceID(meta), + "origin": resolveOriginID(meta), + "taglist": resolveTags(meta), + "desc": strings.TrimSpace(description), + "groupDesc": resolveGroupDescription(meta), + "ignoredupes": "1", + "genre_tags": "---", + "autocomplete_toggle": "on", + "fontfont": "-1", + "fontsize": "-1", + "auth": auth, + "anonymous": anon, + "submit": "true", + } +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: MTV write multipart field %q: %w", key, err) + } + } + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: MTV open torrent file: %w", err) + } + defer file.Close() + part, err := writer.CreateFormFile("file_input", "[MTV].torrent") + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: MTV create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: MTV copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: MTV close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveTorrentPath(meta api.UploadSubject, dbPath string) (string, error) { + candidates := []string{ + strings.TrimSpace(meta.TorrentPath), + strings.TrimSpace(meta.ClientTorrentPath), + strings.TrimSpace(meta.SourcePath), + } + for _, candidate := range candidates { + if candidate == "" || !strings.EqualFold(filepath.Ext(candidate), ".torrent") { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err == nil { + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err == nil { + guessed := filepath.Join(tmpDir, base+".torrent") + if info, err := os.Stat(guessed); err == nil && !info.IsDir() { + return guessed, nil + } + } + } + } + return "", errors.New("trackers: MTV torrent file not found") +} + +func resolveUploadName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.ReleaseName); name != "" { + return cleanName(name) + } + if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { + return cleanName(name) + } + if name := strings.TrimSpace(meta.Filename); name != "" { + return cleanName(name) + } + return cleanName(pathutil.Base(meta.SourcePath)) +} + +func cleanName(value string) string { + value = strings.Join(strings.Fields(value), " ") + value = strings.ReplaceAll(value, " ", ".") + value = strings.ReplaceAll(value, "..", ".") + return strings.TrimSpace(value) +} + +func resolveResolution(meta api.UploadSubject) string { + if value := strings.TrimSpace(meta.Release.Resolution); value != "" { + return value + } + if strings.TrimSpace(meta.UHD) != "" { + return "2160p" + } + return "" +} + +func resolveResolutionID(meta api.UploadSubject) string { + res := strings.ToLower(strings.TrimSpace(resolveResolution(meta))) + values := map[string]string{ + "8640p": "0", + "4320p": "4000", + "2160p": "2160", + "1440p": "1440", + "1080p": "1080", + "1080i": "1080", + "720p": "720", + "576p": "0", + "576i": "0", + "480p": "480", + "480i": "480", + } + if value, ok := values[res]; ok { + return value + } + return "10" +} + +func isSD(meta api.UploadSubject) bool { + res := strings.ToLower(resolveResolution(meta)) + return strings.Contains(res, "480") || strings.Contains(res, "576") +} + +func resolveCategory(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func resolveCategoryID(meta api.UploadSubject) string { + category := resolveCategory(meta) + if category == "MOVIE" { + if isSD(meta) { + return "2" + } + return "1" + } + if category == "TV" { + if meta.TVPack { + if isSD(meta) { + return "6" + } + return "5" + } + if isSD(meta) { + return "4" + } + return "3" + } + return "0" +} + +func resolveType(meta api.UploadSubject) string { + value := strings.ToUpper(strings.TrimSpace(meta.Type)) + if value == "" { + value = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) + } + return strings.ReplaceAll(strings.ReplaceAll(value, "-", ""), " ", "") +} + +func resolveSourceID(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + return "1" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") || resolveType(meta) == "REMUX" { + return "7" + } + mapping := map[string]string{ + "DISC": "1", + "WEBDL": "9", + "WEBRIP": "10", + "HDTV": "1", + "SDTV": "2", + "TVRIP": "3", + "DVD": "4", + "DVDRIP": "5", + "BDRIP": "8", + "VHS": "6", + "MIXED": "11", + "ENCODE": "7", + } + if value, ok := mapping[resolveType(meta)]; ok { + return value + } + return "0" +} + +func resolveOriginID(meta api.UploadSubject) string { + if meta.PersonalRelease { + return "4" + } + if meta.Scene { + return "2" + } + return "3" +} + +func resolveTags(meta api.UploadSubject) string { + tags := make([]string, 0, 12) + resolution := strings.ToLower(strings.TrimSpace(resolveResolution(meta))) + if resolution != "" { + tags = append(tags, resolution) + } + switch { + case isSD(meta): + tags = append(tags, "sd") + case resolution == "2160p" || resolution == "4320p": + tags = append(tags, "uhd") + default: + tags = append(tags, "hd") + } + if service := strings.TrimSpace(meta.ServiceLongName); service != "" { + svc := strings.ToLower(strings.ReplaceAll(service, " ", ".")) + svc = strings.ReplaceAll(svc, "+", "plus") + tags = append(tags, svc+".source") + } + switch category := resolveCategory(meta); category { + case "TV": + switch { + case meta.TVPack && isSD(meta): + tags = append(tags, "sd.season") + case meta.TVPack: + tags = append(tags, "hd.season") + case isSD(meta): + tags = append(tags, "sd.episode") + default: + tags = append(tags, "hd.episode") + } + case "MOVIE": + if isSD(meta) { + tags = append(tags, "sd.movie") + } else { + tags = append(tags, "hd.movie") + } + } + audio := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(meta.Audio, "+", "p"), "-", "."), " ", ".")) + for _, token := range []string{"dd", "ddp", "aac", "truehd", "mp3", "mp2", "dts", "dts.hd", "dts.x"} { + if strings.Contains(audio, token) { + tags = append(tags, token+".audio") + break + } + } + if strings.Contains(strings.ToLower(meta.Audio), "atmos") { + tags = append(tags, "atmos.audio") + } + codec := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(meta.VideoCodec), "avc", "h264"), "hevc", "h265") + codec = strings.ReplaceAll(codec, "-", "") + if strings.TrimSpace(codec) != "" { + tags = append(tags, strings.TrimSpace(codec)) + } + if tag := strings.TrimSpace(meta.Tag); tag != "" { + tags = append(tags, strings.TrimPrefix(strings.ReplaceAll(tag, " ", "."), "-")+".release") + } else { + tags = append(tags, "NOGRP.release") + } + if meta.Scene { + tags = append(tags, "scene.group.release") + } else { + tags = append(tags, "p2p.group.release") + } + return strings.Join(tags, " ") +} + +func resolveGroupDescription(meta api.UploadSubject) string { + parts := make([]string, 0, 5) + if meta.ProviderMetadata.IMDB != nil { + if imdbURL := strings.TrimSpace(meta.ProviderMetadata.IMDB.IMDbURL); imdbURL != "" { + parts = append(parts, imdbURL) + } + } + if meta.Identity.TMDBID != 0 { + category := strings.ToLower(strings.TrimSpace(resolveCategory(meta))) + if category == "" { + category = "movie" + } + parts = append(parts, "https://www.themoviedb.org/"+category+"/"+strconv.Itoa(meta.Identity.TMDBID)) + } + if strings.EqualFold(resolveCategory(meta), "TV") && meta.Identity.TVDBID != 0 { + parts = append(parts, "https://www.thetvdb.com/?id="+strconv.Itoa(meta.Identity.TVDBID)) + } + if meta.Identity.TVmazeID != 0 { + parts = append(parts, "https://www.tvmaze.com/shows/"+strconv.Itoa(meta.Identity.TVmazeID)) + } + if meta.Identity.MALID != 0 { + parts = append(parts, "https://myanimelist.net/anime/"+strconv.Itoa(meta.Identity.MALID)) + } + return strings.Join(parts, "\n") +} diff --git a/internal/trackers/impl/standalone/mtv/upload_test.go b/internal/trackers/impl/standalone/mtv/upload_test.go new file mode 100644 index 00000000..835b5c4c --- /dev/null +++ b/internal/trackers/impl/standalone/mtv/upload_test.go @@ -0,0 +1,749 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mtv + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + servicedb "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveGroupDescriptionSkipsTVDBForMovie(t *testing.T) { + got := resolveGroupDescription(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE", TVDBID: 456}, + }) + if strings.Contains(got, "thetvdb.com") { + t.Fatalf("did not expect tvdb link for movie description, got %q", got) + } +} + +func TestResolveGroupDescriptionIncludesTVDBForTV(t *testing.T) { + got := resolveGroupDescription(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV", TVDBID: 456}, + }) + if !strings.Contains(got, "thetvdb.com/?id=456") { + t.Fatalf("expected tvdb link for TV description, got %q", got) + } +} + +func TestExtractMTVAuthKeyMatchesUploadAssistantShape(t *testing.T) { + t.Parallel() + + auth := "abcdefghijklmnopqrstuvwxyzABCDEF" + tests := map[string]string{ + "plain": "authkey=" + auth, + "query tail": "https://example.test/upload?authkey=" + auth + "&x=1", + "last marker": "authkey=oldoldoldoldoldoldoldoldoldoldoldold authkey=" + auth + `">`, + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if got := extractMTVAuthKey(body); got != auth { + t.Fatal("expected extracted auth key") + } + }) + } +} + +func TestResolveSessionForTrackerAuthPreservesCookiesOnTransientAuthFetch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "abc"}); err != nil { + t.Fatalf("saveMTVCookies: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + baseURL := server.URL + server.Close() + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, baseURL) + if err == nil { + t.Fatal("expected transient auth fetch error") + } + got, loadErr := loadMTVCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadMTVCookies after transient error: %v", loadErr) + } + if got["session"] != "abc" { + t.Fatalf("expected transient failure to preserve cookies, got %#v", got) + } +} + +func TestResolveSessionForTrackerAuthReportsPostLoginCookiePersistenceFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "abc", + Path: "/", + }) + _, _ = w.Write([]byte(``)) + case "/index.php": + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthAt(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, filepath.Join(t.TempDir(), "upbrr.db"), server.URL) + if !errors.Is(err, cookiepkg.ErrAuthHelperUnavailable) { + t.Fatalf("expected auth helper unavailable error, got %v", err) + } + if !strings.Contains(err.Error(), "persist cookies after successful login") { + t.Fatalf("expected distinct persistence failure, got %v", err) + } +} + +func TestResolveSessionForTrackerAuthSavesCookiesWhenLoginResponseContainsAuthKey(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + indexRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "fresh", + Path: "/", + }) + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + case "/index.php": + indexRequests++ + _, _ = w.Write([]byte(`logged out`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, server.URL) + if err != nil { + t.Fatalf("ResolveSessionForTrackerAuth: %v", err) + } + if indexRequests != 0 { + t.Fatalf("expected login response authkey to avoid index lookup, got %d", indexRequests) + } + got, err := loadMTVCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadMTVCookies: %v", err) + } + if got["session"] != "fresh" { + t.Fatalf("expected saved login cookies, got %#v", got) + } +} + +func TestLoginAndResolveAuthKeyReturnsLoginPageReadError(t *testing.T) { + t.Parallel() + + handlerErrs := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/login" && r.Method == http.MethodGet: + if err := writeMalformedChunkedResponse(w); err != nil { + recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) + } + default: + recordHandlerError(handlerErrs, "unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + _, _, _, _, err := loginAndResolveAuthKey(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, server.URL, api.TrackerAuthLoginRequest{}) + assertNoHandlerError(t, handlerErrs) + if err == nil { + t.Fatal("expected login page read error") + } + if !strings.Contains(err.Error(), "read login page response") { + t.Fatalf("expected login page read-error context, got %v", err) + } +} + +func TestLoginAndResolveAuthKeyReturnsLoginPostReadError(t *testing.T) { + t.Parallel() + + handlerErrs := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/login" && r.Method == http.MethodGet: + _, _ = w.Write([]byte(``)) + case r.URL.Path == "/login" && r.Method == http.MethodPost: + if err := writeMalformedChunkedResponse(w); err != nil { + recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) + } + default: + recordHandlerError(handlerErrs, "unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + _, _, _, _, err := loginAndResolveAuthKey(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, server.URL, api.TrackerAuthLoginRequest{}) + assertNoHandlerError(t, handlerErrs) + if err == nil { + t.Fatal("expected login response read error") + } + if !strings.Contains(err.Error(), "read login response") { + t.Fatalf("expected login response read-error context, got %v", err) + } +} + +func TestResolveSessionForTrackerAuthPostsLoginToRedirectedHost(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + var canonicalURL string + postedCanonicalLogin := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/login" && r.Method == http.MethodGet && strings.HasPrefix(r.Host, "localhost:"): + http.Redirect(w, r, canonicalURL+"/login", http.StatusFound) + case r.URL.Path == "/login" && r.Method == http.MethodGet: + _, _ = w.Write([]byte(``)) + case r.URL.Path == "/login" && r.Method == http.MethodPost && strings.HasPrefix(r.Host, "localhost:"): + t.Error("login POST used original host") + return + case r.URL.Path == "/login" && r.Method == http.MethodPost: + postedCanonicalLogin = true + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "fresh", + Path: "/", + }) + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + canonicalURL = server.URL + sourceURL := strings.Replace(server.URL, "127.0.0.1", "localhost", 1) + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, sourceURL) + if err != nil { + t.Fatalf("ResolveSessionForTrackerAuth: %v", err) + } + if !postedCanonicalLogin { + t.Fatal("expected login POST on redirected host") + } + got, err := loadMTVCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadMTVCookies: %v", err) + } + if got["session"] != "fresh" { + t.Fatalf("expected saved redirected-login cookies, got %#v", got) + } +} + +func TestUploadPostsToRedirectedLoginHost(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + torrentPath := filepath.Join(t.TempDir(), "release.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + var canonicalURL string + postedCanonicalUpload := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/login" && r.Method == http.MethodGet && strings.HasPrefix(r.Host, "localhost:"): + http.Redirect(w, r, canonicalURL+"/login", http.StatusFound) + case r.URL.Path == "/login" && r.Method == http.MethodGet: + _, _ = w.Write([]byte(``)) + case r.URL.Path == "/login" && r.Method == http.MethodPost: + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "fresh", + Path: "/", + }) + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + case r.URL.Path == mtvUploadPath && r.Method == http.MethodPost && strings.HasPrefix(r.Host, "localhost:"): + t.Error("upload POST used original host") + return + case r.URL.Path == mtvUploadPath && r.Method == http.MethodPost: + postedCanonicalUpload = true + if _, err := r.Cookie("session"); err != nil { + t.Errorf("expected session cookie on canonical upload: %v", err) + return + } + http.Redirect(w, r, canonicalURL+"/torrents.php?id=1", http.StatusFound) + case r.URL.Path == "/torrents.php": + _, _ = w.Write([]byte("ok")) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + canonicalURL = server.URL + sourceURL := strings.Replace(server.URL, "127.0.0.1", "localhost", 1) + + summary, err := uploadAt(ctx, trackers.PreparationInput{ + Tracker: "MTV", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + ReleaseName: "Release", + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Password: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Assets: &trackers.DescriptionAssets{Final: true, Description: "desc"}, + }, sourceURL) + if err != nil { + t.Fatalf("upload: %v", err) + } + if summary.Uploaded != 1 { + t.Fatalf("expected upload success, got %#v", summary) + } + if !postedCanonicalUpload { + t.Fatal("expected upload POST on redirected host") + } +} + +func TestResolveSessionForTrackerAuthRejectsEmptyLoginCookiesWithoutReplacingStoredCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { + t.Fatalf("saveMTVCookies: %v", err) + } + indexRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + _, _ = w.Write([]byte(`ok`)) + case "/index.php": + indexRequests++ + if indexRequests == 1 { + _, _ = w.Write([]byte(`logged out`)) + return + } + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, server.URL) + if err == nil || !strings.Contains(err.Error(), "no usable cookies") { + t.Fatalf("expected empty login cookie error, got %v", err) + } + got, loadErr := loadMTVCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadMTVCookies after empty login cookies: %v", loadErr) + } + if got["session"] != "existing" { + t.Fatalf("expected empty login cookies to preserve stored cookies, got %#v", got) + } +} + +func TestResolveSessionForTrackerAuthDoesNotPersistCookiesBeforeAuthKeyValidation(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "unverified", + Path: "/", + }) + _, _ = w.Write([]byte(`logged in without auth key`)) + case "/index.php": + _, _ = w.Write([]byte(`temporary account notice`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, server.URL) + if err == nil || !strings.Contains(err.Error(), "auth key not found") { + t.Fatalf("expected auth-key validation failure, got %v", err) + } + if values, loadErr := loadMTVCookies(ctx, dbPath); !errors.Is(loadErr, cookiepkg.ErrTrackerCookiesNotFound) { + t.Fatalf("unverified login cookies persisted or unexpected load error values=%#v err=%v", values, loadErr) + } +} + +func TestResolveSessionForTrackerAuthLoginUsesManual2FACode(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + var gotCode string + indexRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.php": + indexRequests++ + _, _ = w.Write([]byte(`logged out`)) + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.Redirect(w, r, "/twofactor/login", http.StatusFound) + case "/twofactor/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + return + } + gotCode = r.FormValue("code") + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, api.TrackerAuthLoginRequest{Code: "654321"}, server.URL) + if err != nil { + t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) + } + if gotCode != "654321" { + t.Fatalf("expected manual 2FA code, got %q", gotCode) + } + if indexRequests != 0 { + t.Fatalf("expected 2FA response authkey to avoid index lookup, got %d", indexRequests) + } + got, err := loadMTVCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadMTVCookies: %v", err) + } + if got["session"] != "new" { + t.Fatalf("expected saved 2FA login cookies, got %#v", got) + } +} + +func TestResolveSessionForTrackerAuthLoginMarksSubmitted2FAAuthKeyMiss(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { + t.Fatalf("saveMTVCookies: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.php": + _, _ = w.Write([]byte(`logged out`)) + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.Redirect(w, r, "/twofactor/login", http.StatusFound) + case "/twofactor/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + _, _ = w.Write([]byte(`invalid code`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}, server.URL) + if !errors.Is(err, ErrSubmitted2FARejected) { + t.Fatalf("expected submitted 2FA rejection marker, got %v", err) + } + if !strings.Contains(err.Error(), "final_path=/twofactor/login status=200") || !strings.Contains(err.Error(), "path=/index.php status=200") { + t.Fatalf("expected safe path/status diagnostics, got %v", err) + } + got, loadErr := loadMTVCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadMTVCookies: %v", loadErr) + } + if got["session"] != "existing" { + t.Fatalf("submitted 2FA rejection must preserve stored cookies, got %#v", got) + } +} + +func TestResolveSessionForTrackerAuthLoginReturns2FAReadError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { + t.Fatalf("saveMTVCookies: %v", err) + } + handlerErrs := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.php": + _, _ = w.Write([]byte(`logged out`)) + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.Redirect(w, r, "/twofactor/login", http.StatusFound) + case "/twofactor/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + if err := writeMalformedChunkedResponse(w); err != nil { + recordHandlerError(handlerErrs, "write malformed chunked response: %v", err) + } + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, api.TrackerAuthLoginRequest{Code: "123456"}, server.URL) + assertNoHandlerError(t, handlerErrs) + if err == nil { + t.Fatal("expected 2FA response read error") + } + if errors.Is(err, ErrSubmitted2FARejected) { + t.Fatalf("read error must not be classified as submitted 2FA rejection: %v", err) + } + if !strings.Contains(err.Error(), "read 2FA login response") { + t.Fatalf("expected read-error context, got %v", err) + } + got, loadErr := loadMTVCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadMTVCookies: %v", loadErr) + } + if got["session"] != "existing" { + t.Fatalf("2FA read error must preserve stored cookies, got %#v", got) + } +} + +func TestResolveSessionForTrackerAuthLoginReportsSafeAuthKeyDiagnostics(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`login accepted but no upload token secret-response-body`)) + case "/index.php": + _, _ = w.Write([]byte(`logged in but no upload token other-secret-body`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) + if !errors.Is(err, errMTVAuthKeyNotFound) { + t.Fatalf("expected auth key miss, got %v", err) + } + if !strings.Contains(err.Error(), "final_path=/login status=200") || !strings.Contains(err.Error(), "path=/index.php status=200") { + t.Fatalf("expected path/status diagnostics, got %v", err) + } + for _, secret := range []string{"secret-response-body", "other-secret-body"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("diagnostic leaked response body %q: %v", secret, err) + } + } +} + +func TestResolveSessionForTrackerAuthLoginMissing2FACodePreservesCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newMTVAuthDB(t) + if err := saveMTVCookies(ctx, dbPath, map[string]string{"session": "existing"}); err != nil { + t.Fatalf("saveMTVCookies: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.php": + _, _ = w.Write([]byte(`logged out`)) + case "/login": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(``)) + return + } + http.Redirect(w, r, "/twofactor/login", http.StatusFound) + case "/twofactor/login": + _, _ = w.Write([]byte(``)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) + if err == nil || !strings.Contains(err.Error(), "2FA required") { + t.Fatalf("expected missing 2FA error, got %v", err) + } + got, loadErr := loadMTVCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadMTVCookies: %v", loadErr) + } + if got["session"] != "existing" { + t.Fatalf("missing 2FA code must preserve stored cookies, got %#v", got) + } +} + +func newMTVAuthDB(t *testing.T) string { + t.Helper() + + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + repo, err := servicedb.OpenWithLoggerContext(ctx, dbPath, api.NopLogger{}) + if err != nil { + t.Fatalf("OpenWithLoggerContext: %v", err) + } + if err := repo.MigrateContext(ctx); err != nil { + _ = repo.Close() + t.Fatalf("MigrateContext: %v", err) + } + _ = repo.Close() + return dbPath +} + +// recordHandlerError preserves handler failures for assertion from the test +// goroutine, where testing.T failures are safe to report. +func recordHandlerError(errs chan<- string, format string, args ...any) { + select { + case errs <- fmt.Sprintf(format, args...): + default: + } +} + +// assertNoHandlerError fails the test if the server handler recorded a setup or +// routing error while servicing the request under test. +func assertNoHandlerError(t *testing.T, errs <-chan string) { + t.Helper() + + select { + case err := <-errs: + t.Fatal(err) + default: + } +} + +// writeMalformedChunkedResponse sends an intentionally truncated chunked body so +// callers exercise response-body read errors after headers are received. +func writeMalformedChunkedResponse(w http.ResponseWriter) error { + hijacker, ok := w.(http.Hijacker) + if !ok { + return errors.New("response writer does not support hijacking") + } + conn, rw, err := hijacker.Hijack() + if err != nil { + return fmt.Errorf("hijack response: %w", err) + } + if _, err := rw.WriteString("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n20\r\npartial"); err != nil { + _ = conn.Close() + return fmt.Errorf("write response: %w", err) + } + if err := rw.Flush(); err != nil { + _ = conn.Close() + return fmt.Errorf("flush response: %w", err) + } + _ = conn.Close() + return nil +} diff --git a/internal/trackers/impl/standalone/nbl/banned_groups.go b/internal/trackers/impl/standalone/nbl/banned_groups.go new file mode 100644 index 00000000..1825b580 --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/banned_groups.go @@ -0,0 +1,77 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +// bannedGroups returns NBL's static release-group blacklist. +func bannedGroups() []string { + return []string{ + "0neshot", + "3LTON", + "4yEo", + "[Oj]", + "AFG", + "AkihitoSubs", + "AniHLS", + "Anime Time", + "AnimeRG", + "AniURL", + "ASW", + "BakedFish", + "bonkai77", + "Cleo", + "DeadFish", + "DeeJayAhmed", + "ELiTE", + "EMBER", + "eSc", + "EVO", + "FGT", + "FUM", + "GERMini", + "HAiKU", + "Hi10", + "ION10", + "JacobSwaggedUp", + "JIVE", + "Judas", + "LOAD", + "MeGusta", + "Mr.Deadpool", + "mSD", + "NemDiggers", + "neoHEVC", + "NhaNc3", + "NOIVTC", + "PlaySD", + "playXD", + "project-gxs", + "PSA", + "QaS", + "Ranger", + "RAPiDCOWS", + "Raze", + "Reaktor", + "REsuRRecTioN", + "RMTeam", + "ROBOTS", + "SpaceFish", + "SPASM", + "SSA", + "Telly", + "Tenrai-Sensei", + "TM", + "Trix", + "URANiME", + "VipapkStudios", + "ViSiON", + "Wardevil", + "xRed", + "XS", + "YakuboEncodes", + "YuiSubs", + "ZKBL", + "ZmN", + "ZMNT", + } +} diff --git a/internal/trackers/impl/standalone/nbl/definition_test.go b/internal/trackers/impl/standalone/nbl/definition_test.go new file mode 100644 index 00000000..10ea45d3 --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/definition_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDefinitionBuildUploadDryRunBuildsPayload(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "MEDIAINFO.txt") + torrentPath := filepath.Join(tmp, "Show.torrent") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + plan, failure := New().Prepare(context.Background(), trackers.PreparationInput{ + Tracker: "NBL", + Intent: trackers.PreparationIntentDryRun, + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Show.mkv"), + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Identity: api.ExternalIdentity{TVmazeID: 987}, + TVPack: true, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{}), + Logger: api.NopLogger{}, + }) + if failure != nil { + t.Fatalf("unexpected failure: %v", failure) + } + entry := plan.DryRun() + if entry.Payload["tvmazeid"] != "987" { + t.Fatalf("expected tvmazeid 987, got %q", entry.Payload["tvmazeid"]) + } + if entry.Payload["category"] != "3" { + t.Fatalf("expected pack category 3, got %q", entry.Payload["category"]) + } + if entry.Questionnaire != nil { + t.Fatal("expected no questionnaire") + } +} diff --git a/internal/trackers/impl/standalone/nbl/dupe.go b/internal/trackers/impl/standalone/nbl/dupe.go new file mode 100644 index 00000000..ecfb9168 --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/dupe.go @@ -0,0 +1,120 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := nblAPIKey(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + searchTerm := map[string]any{} + switch { + case meta.Identity.TVmazeID != 0: + searchTerm["tvmaze"] = meta.Identity.TVmazeID + case meta.Identity.IMDBID != 0: + searchTerm["imdb"] = strconv.Itoa(meta.Identity.IMDBID) + default: + searchTerm["series"] = strings.TrimSpace(meta.Release.Title) + } + raw, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "getTorrents", + "params": []any{apiKey, searchTerm}, + }) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "NBL search failed", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://nebulance.io/api.php", bytes.NewReader(raw)) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "NBL search failed", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "NBL search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "NBL search failed", nil) + } + var payload struct { + Result struct { + Items []map[string]any `json:"items"` + } `json:"result"` + } + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return dupe.Failed(dupe.FailureResponseParse, "NBL search failed", err) + } + entries := make([]api.DupeEntry, 0, len(payload.Result.Items)) + for _, item := range payload.Result.Items { + entry := api.DupeEntry{ + Name: nblString(item["rls_name"]), + Link: "https://nebulance.io/torrents.php?id=" + nblString(item["group_id"]), + Download: nblString(item["download"]), + } + if size := nblInt64(item["size"]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + if files, ok := item["file_list"].([]any); ok { + entry.FileCount = len(files) + for _, file := range files { + if name := nblString(file); name != "" { + entry.Files = append(entry.Files, name) + } + } + } + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +func nblAPIKey(cfg config.Config) string { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "NBL") { + return strings.TrimSpace(entry.APIKey) + } + } + return "" +} +func nblInt64(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/nbl/profile.go b/internal/trackers/impl/standalone/nbl/profile.go new file mode 100644 index 00000000..ad2c8070 --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/profile.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns NBL identity, preparation, dupe, rules, bans, and policies, +// including the strict TVmaze metadata requirement for TV uploads. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "NBL", + BaseURL: "https://nebulance.io", + DescriptionGroup: "nbl", + UploadContentMode: trackers.UploadContentModeNone, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + BannedGroups: bannedGroups(), + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeTV, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTVmaze}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "NBL"}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"tracker.nebulance"}}, + } +} + +// New returns a fresh NBL definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/nbl/rules.go b/internal/trackers/impl/standalone/nbl/rules.go new file mode 100644 index 00000000..479817ea --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/rules.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import "github.com/autobrr/upbrr/internal/trackers" + +// rules declares NBL's TV-only and English-language requirements. +func rules() *trackers.RuleSet { + return &trackers.RuleSet{ + RequireTVOnly: true, + Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonBDMV: true, + }, + } +} diff --git a/internal/trackers/impl/standalone/nbl/upload.go b/internal/trackers/impl/standalone/nbl/upload.go new file mode 100644 index 00000000..9beeea39 --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/upload.go @@ -0,0 +1,253 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/httpclient" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const nblUploadURL = "https://nebulance.io/api.php" + +var nblTorrentIDPattern = regexp.MustCompile(`id=(\d+)`) + +type uploadState struct { + torrentPath string + releaseName string + fields map[string]string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath) + if err != nil { + return trackers.PreparedOperation{}, err + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "NBL") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, state, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + state uploadState, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, nblUploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: NBL request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + result, err := commonhttp.ExecuteUpload(httpclient.New(httpclient.DefaultTimeout), httpReq, commonhttp.UploadExecutionOptions{Tracker: "NBL"}) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: NBL execute upload: %w", err) + } + if !result.Success { + return api.UploadSummary{}, commonhttp.UploadHTTPError("NBL", result.StatusCode, result.Preview) + } + + payload := map[string]any{} + if len(result.Body) > 0 { + if err := json.Unmarshal(result.Body, &payload); err != nil { + return api.UploadSummary{}, errors.New("trackers: NBL json decode error, the API is probably down") + } + } + + torrentURL, torrentID := extractUploadLinkAndID(payload) + + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, torrentURL, "NBL"); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "NBL", + TorrentID: torrentID, + DownloadURL: torrentURL, + TorrentURL: torrentURL, + TorrentPath: artifactPath, + }}, + }, nil +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "NBL", + ReleaseName: state.releaseName, + DescriptionGroup: "nbl", + Endpoint: nblUploadURL, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + select { + case <-ctx.Done(): + return uploadState{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: NBL missing api_key") + } + + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + mediaInfo, err := resolveMediaInfo(req.Meta) + if err != nil { + return uploadState{}, err + } + + fields := map[string]string{ + "action": "upload", + "api_key": strings.TrimSpace(req.TrackerConfig.APIKey), + "tvmazeid": strconv.Itoa(req.Meta.Identity.TVmazeID), + "mediainfo": mediaInfo, + "category": strconv.Itoa(resolveCategoryID(req.Meta)), + "ignoredupes": "1", + } + + return uploadState{ + torrentPath: torrentPath, + releaseName: resolveUploadName(req.Meta), + fields: fields, + }, nil +} + +func buildMultipartPayload(fields map[string]string, torrentPath string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: NBL write multipart field %q: %w", key, err) + } + } + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: NBL open torrent file: %w", err) + } + defer file.Close() + part, err := writer.CreateFormFile("file_input", "torrent.torrent") + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: NBL create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: NBL copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: NBL close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveMediaInfo(meta api.UploadSubject) (string, error) { + if strings.TrimSpace(meta.MediaInfoTextPath) == "" { + return "", errors.New("trackers: NBL missing mediainfo text") + } + payload, err := os.ReadFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + return "", fmt.Errorf("trackers: NBL read mediainfo: %w", err) + } + return string(payload), nil +} + +func resolveCategoryID(meta api.UploadSubject) int { + if meta.TVPack { + return 3 + } + return 1 +} + +func resolveUploadName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.ReleaseName); name != "" { + return name + } + if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { + return name + } + if name := strings.TrimSpace(meta.Filename); name != "" { + return name + } + return pathutil.Base(meta.SourcePath) +} + +func nblString(value any) string { + if value == nil { + return "" + } + + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func extractUploadLinkAndID(payload map[string]any) (string, string) { + torrentURL := strings.TrimSpace(nblString(payload["link"])) + if torrentURL == "" { + if result, ok := payload["result"].(map[string]any); ok { + torrentURL = strings.TrimSpace(nblString(result["link"])) + } + } + + torrentID := "" + if matches := nblTorrentIDPattern.FindStringSubmatch(torrentURL); len(matches) > 1 { + torrentID = strings.TrimSpace(matches[1]) + } + + return torrentURL, torrentID +} diff --git a/internal/trackers/impl/standalone/nbl/upload_test.go b/internal/trackers/impl/standalone/nbl/upload_test.go new file mode 100644 index 00000000..3a2a1a2c --- /dev/null +++ b/internal/trackers/impl/standalone/nbl/upload_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package nbl + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestPrepareUploadStateUsesNumericIgnoreDupes(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "release.torrent") + mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") + if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + if err := os.WriteFile(mediaInfoPath, []byte("mediainfo"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + state, err := prepareUploadState(context.Background(), trackers.PreparationInput{ + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + }, + TrackerConfig: config.TrackerConfig{APIKey: "api-key"}, + }) + if err != nil { + t.Fatalf("prepare upload state: %v", err) + } + if got := state.fields["ignoredupes"]; got != "1" { + t.Fatalf("expected ignoredupes=1, got %q", got) + } +} + +func TestExtractUploadLinkAndIDFromRootLink(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "link": "https://nebulance.io/torrents.php?id=12345", + } + + link, id := extractUploadLinkAndID(payload) + if link != "https://nebulance.io/torrents.php?id=12345" { + t.Fatalf("expected upload link from root, got %q", link) + } + if id != "12345" { + t.Fatalf("expected torrent id 12345, got %q", id) + } +} + +func TestExtractUploadLinkAndIDFromResultLink(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "result": map[string]any{ + "link": "https://nebulance.io/torrents.php?id=67890", + }, + } + + link, id := extractUploadLinkAndID(payload) + if link != "https://nebulance.io/torrents.php?id=67890" { + t.Fatalf("expected upload link from nested result, got %q", link) + } + if id != "67890" { + t.Fatalf("expected torrent id 67890, got %q", id) + } +} + +func TestExtractUploadLinkAndIDEmptyWhenMissingLink(t *testing.T) { + t.Parallel() + + payload := map[string]any{"status": "ok"} + + link, id := extractUploadLinkAndID(payload) + if link != "" { + t.Fatalf("expected empty upload link when missing, got %q", link) + } + if id != "" { + t.Fatalf("expected empty torrent id when link missing, got %q", id) + } +} diff --git a/internal/trackers/impl/standalone/preview.go b/internal/trackers/impl/standalone/preview.go new file mode 100644 index 00000000..f5175926 --- /dev/null +++ b/internal/trackers/impl/standalone/preview.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +// Package standalone owns shared lifecycle and definition behavior for tracker +// implementations that do not belong to a protocol family. +package standalone + +import ( + "maps" + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +// PreviewSpec contains tracker-local facts projected into an upload preview. +type PreviewSpec struct { + Tracker string + ReadyMessage string + BlockedReason string + ReleaseName string + DescriptionGroup string + Description string + Endpoint string + Payload map[string]string + Files []api.TrackerDryRunFile + Questionnaire *api.TrackerQuestionnaire + DebugSections []api.TrackerDryRunDebugSection + ImageHost api.ImageHostFeedback +} + +// BuildPreview returns a defensively copied ready or blocked preview from +// tracker-local prepared facts. +func BuildPreview(spec PreviewSpec) api.TrackerDryRunEntry { + status := "ready" + message := strings.TrimSpace(spec.ReadyMessage) + if message == "" { + message = "dry-run payload generated" + } + if reason := strings.TrimSpace(spec.BlockedReason); reason != "" { + status = "blocked" + message = reason + } + entry := api.TrackerDryRunEntry{ + Tracker: strings.ToUpper(strings.TrimSpace(spec.Tracker)), + Status: status, + Message: message, + ReleaseName: strings.TrimSpace(spec.ReleaseName), + DescriptionGroup: strings.ToLower(strings.TrimSpace(spec.DescriptionGroup)), + Description: spec.Description, + Endpoint: strings.TrimSpace(spec.Endpoint), + Payload: maps.Clone(spec.Payload), + Files: append([]api.TrackerDryRunFile(nil), spec.Files...), + DebugSections: append([]api.TrackerDryRunDebugSection(nil), spec.DebugSections...), + ImageHost: spec.ImageHost, + } + for idx := range entry.DebugSections { + entry.DebugSections[idx].Payload = maps.Clone(entry.DebugSections[idx].Payload) + entry.DebugSections[idx].Files = append([]api.TrackerDryRunFile(nil), entry.DebugSections[idx].Files...) + } + if spec.Questionnaire != nil { + questionnaire := *spec.Questionnaire + questionnaire.Fields = append([]api.TrackerQuestionnaireField(nil), questionnaire.Fields...) + for idx := range questionnaire.Fields { + questionnaire.Fields[idx].Options = append([]string(nil), questionnaire.Fields[idx].Options...) + } + entry.Questionnaire = &questionnaire + } + entry.ImageHost.AllowedHosts = append([]string(nil), spec.ImageHost.AllowedHosts...) + entry.ImageHost.Warnings = append([]api.ImageHostWarning(nil), spec.ImageHost.Warnings...) + return entry +} diff --git a/internal/trackers/impl/standalone/preview_test.go b/internal/trackers/impl/standalone/preview_test.go new file mode 100644 index 00000000..979e06b3 --- /dev/null +++ b/internal/trackers/impl/standalone/preview_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildPreviewNormalizesStatusAndDefensivelyCopies(t *testing.T) { + t.Parallel() + + spec := PreviewSpec{ + Tracker: " dc ", + BlockedReason: "missing metadata", + ReleaseName: " Example.Release.2026.1080p-GRP ", + DescriptionGroup: " DC ", + Payload: map[string]string{"name": "prepared"}, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: "example.torrent", + Present: true, + }}, + } + entry := BuildPreview(spec) + spec.Payload["name"] = "mutated" + spec.Files[0].Path = "mutated" + if entry.Tracker != "DC" || entry.Status != "blocked" || entry.Message != "missing metadata" { + t.Fatalf("preview identity/status = %#v", entry) + } + if entry.ReleaseName != "Example.Release.2026.1080p-GRP" || entry.DescriptionGroup != "dc" { + t.Fatalf("preview projection = %#v", entry) + } + if entry.Payload["name"] != "prepared" || entry.Files[0].Path != "example.torrent" { + t.Fatalf("preview retained caller mutation: %#v", entry) + } +} diff --git a/internal/trackers/impl/standalone/profile.go b/internal/trackers/impl/standalone/profile.go new file mode 100644 index 00000000..52c3b5ca --- /dev/null +++ b/internal/trackers/impl/standalone/profile.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "context" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +// CookieAuthCapability returns the standard stored-cookie capability for a tracker. +func CookieAuthCapability(name string) *api.TrackerAuthCapability { + name = strings.ToUpper(strings.TrimSpace(name)) + return &api.TrackerAuthCapability{ + TrackerID: name, + DisplayName: name, + AuthKind: "cookies", + SupportsCookieFile: true, + } +} + +// DescriptionPreparer builds tracker-local description content for one intent. +type DescriptionPreparer func(context.Context, trackers.PreparationInput) (trackers.DescriptionResult, error) + +// Profile is the construction input for one standalone tracker's identity, +// preparation callbacks, duplicate adapter, and declarative capabilities. +// [New] normalizes identity fields and copies mutable policy data before the +// definition is published. +type Profile struct { + Name string + BaseURL string + DescriptionGroup string + LocalizedMetadataLocale string + UploadContentMode trackers.UploadContentMode + PrepareDescription DescriptionPreparer + PrepareUpload trackers.UploadPreparer + NewDuplicateAdapter func(dupe.Dependencies) dupe.Adapter + Rules *trackers.RuleSet + ClaimPolicy *trackers.ClaimPolicy + DataPolicy *trackers.DataLookupPolicy + ArtifactPolicy *trackers.ArtifactPolicy + BannedGroups []string + BannedGroupPolicy *trackers.BannedGroupPolicy + MetadataPolicy *trackers.TrackerMetadataPolicy + UploadArtifactPolicy *trackers.UploadArtifactPolicy + DupePolicy *trackers.DupePolicy + AudioPolicy *trackers.AudioPolicy + ImageHostPolicy *trackers.ImageHostPolicy + TorrentIdentityPolicy *trackers.TorrentIdentityPolicy + AuthCapability *api.TrackerAuthCapability + AuthResolver trackers.AuthSessionResolver + AuthPolicy *trackers.AuthPolicy + AuthStateManager trackers.AuthStateManager +} diff --git a/internal/trackers/impl/standalone/ptp/bbcode.go b/internal/trackers/impl/standalone/ptp/bbcode.go new file mode 100644 index 00000000..faaae03d --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/bbcode.go @@ -0,0 +1,12 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import "github.com/autobrr/upbrr/internal/bbcode" + +// CleanDescription removes PTP-specific description markup and returns the +// cleaned text together with extracted non-comparison images. +func CleanDescription(description string, discType string) bbcode.Report { + return bbcode.CleanPTPDescription(description, discType) +} diff --git a/internal/trackers/impl/standalone/ptp/data.go b/internal/trackers/impl/standalone/ptp/data.go new file mode 100644 index 00000000..a14ac623 --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/data.go @@ -0,0 +1,201 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +type dataLookup struct { + cfg config.Config + http *http.Client + endpoint string +} + +type ptpLookup struct { + trackerID string + imdbID int + infoHash string +} + +// NewDataLookup returns a PTP lookup using the definition's endpoint and the +// supplied immutable config and HTTP client. +func (d *Definition) NewDataLookup(cfg config.Config, httpClient *http.Client, _ api.Logger) trackers.DataLookup { + return &dataLookup{ + cfg: cfg, + http: httpClient, + endpoint: strings.TrimRight(d.baseURL, "/") + ptpTorrentPath, + } +} + +// Lookup resolves a torrent by tracker ID or release-name search, then +// independently projects its identifiers, cleaned description, and images. +// Missing credentials, non-success responses, and misses return an empty or +// partial result without error. +func (l *dataLookup) Lookup(ctx context.Context, req trackers.DataLookupRequest) (trackers.DataLookupResult, error) { + apiUser, apiKey := ptpAPIKeys(l.cfg) + if apiUser == "" || apiKey == "" { + return trackers.DataLookupResult{}, nil + } + headers := map[string]string{"ApiUser": apiUser, "ApiKey": apiKey} + foundID := strings.TrimSpace(req.TrackerID) + var found ptpLookup + var err error + if foundID != "" { + found, err = l.fetchByID(ctx, headers, foundID) + } else { + found, err = l.search(ctx, headers, req.SearchName) + foundID = found.trackerID + } + if err != nil { + return trackers.DataLookupResult{}, err + } + if found.imdbID == 0 && foundID == "" { + return trackers.DataLookupResult{}, nil + } + result := trackers.DataLookupResult{ + TrackerID: foundID, + IMDBID: found.imdbID, + InfoHash: found.infoHash, + } + if foundID == "" || req.OnlyID && !req.KeepImages { + return result, nil + } + description, err := l.description(ctx, headers, foundID) + if err != nil { + return result, err + } + report := CleanDescription(description, req.Meta.DiscType) + if !req.OnlyID { + result.Description = strings.TrimSpace(report.Description) + } + if req.KeepImages { + result.Images = report.Images + } + return result, nil +} + +func (l *dataLookup) fetchByID(ctx context.Context, headers map[string]string, id string) (ptpLookup, error) { + body, err := l.getJSON(ctx, url.Values{"torrentid": {id}}, headers) + if err != nil || len(body) == 0 { + return ptpLookup{}, err + } + return parsePTPResponse(body, id, ""), nil +} + +func (l *dataLookup) search(ctx context.Context, headers map[string]string, search string) (ptpLookup, error) { + if strings.TrimSpace(search) == "" { + return ptpLookup{}, nil + } + body, err := l.getJSON(ctx, url.Values{"searchstr": {search}}, headers) + if err != nil || len(body) == 0 { + return ptpLookup{}, err + } + for _, raw := range ptpSlice(body["Movies"]) { + item := ptpMap(raw) + lookup := parsePTPResponse(item, "", search) + if id := ptpInt(item["ImdbId"]); id != 0 { + lookup.imdbID = id + } + if lookup.imdbID != 0 || lookup.trackerID != "" { + return lookup, nil + } + } + return ptpLookup{}, nil +} + +func (l *dataLookup) description(ctx context.Context, headers map[string]string, id string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.endpoint, nil) + if err != nil { + return "", fmt.Errorf("trackerdata: ptp description request: %w", err) + } + req.URL.RawQuery = url.Values{"id": {id}, "action": {"get_description"}}.Encode() + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := l.http.Do(req) + if err != nil { + return "", fmt.Errorf("trackerdata: ptp description request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", nil + } + payload, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("trackerdata: ptp description read: %w", err) + } + return string(payload), nil +} + +func (l *dataLookup) getJSON(ctx context.Context, params url.Values, headers map[string]string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.endpoint, nil) + if err != nil { + return nil, fmt.Errorf("trackerdata: request: %w", err) + } + req.URL.RawQuery = params.Encode() + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := l.http.Do(req) + if err != nil { + return nil, fmt.Errorf("trackerdata: request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil + } + var result map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + return nil, fmt.Errorf("trackerdata: decode: %w", err) + } + return result, nil +} + +func parsePTPResponse(body map[string]any, trackerID, searchTerm string) ptpLookup { + selectedID, infoHash := strings.TrimSpace(trackerID), "" + needle := strings.ToLower(strings.TrimSpace(searchTerm)) + for _, raw := range ptpSlice(body["Torrents"]) { + item := ptpMap(raw) + id := ptpString(item["Id"]) + releaseName := strings.ToLower(ptpString(item["ReleaseName"])) + if selectedID == "" && needle != "" && strings.Contains(releaseName, needle) { + selectedID, infoHash = id, ptpString(item["InfoHash"]) + break + } + if selectedID != "" && selectedID == id { + infoHash = ptpString(item["InfoHash"]) + break + } + if selectedID == "" { + selectedID, infoHash = id, ptpString(item["InfoHash"]) + } + } + return ptpLookup{ + trackerID: selectedID, + imdbID: ptpInt(body["ImdbId"]), + infoHash: infoHash, + } +} + +func ptpMap(value any) map[string]any { result, _ := value.(map[string]any); return result } +func ptpSlice(value any) []any { result, _ := value.([]any); return result } +func ptpInt(value any) int { + trimmed := strings.TrimPrefix(ptpString(value), "tt") + parsed, _ := strconv.Atoi(trimmed) + return parsed +} diff --git a/internal/trackers/impl/standalone/ptp/definition.go b/internal/trackers/impl/standalone/ptp/definition.go new file mode 100644 index 00000000..56ea81d2 --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/definition.go @@ -0,0 +1,99 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" +) + +// DataLookupConfigured reports whether PTP metadata lookup credentials are available. +func (d *Definition) DataLookupConfigured(cfg config.Config) bool { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "PTP") { + return strings.TrimSpace(entry.PTPAPIUser) != "" && strings.TrimSpace(entry.PTPAPIKey) != "" + } + } + return false +} +func bannedGroups() []string { + return []string{ + "aXXo", + "BMDru", + "BRrip", + "CM8", + "CrEwSaDe", + "CTFOH", + "d3g", + "DNL", + "FaNGDiNG0", + "HD2DVD", + "HDT", + "HDTime", + "ION10", + "iPlanet", + "KiNGDOM", + "mHD", + "mSD", + "nHD", + "nikt0", + "nSD", + "NhaNc3", + "OFT", + "PRODJi", + "SANTi", + "SPiRiT", + "STUTTERSHIT", + "ViSION", + "VXT", + "WAF", + "x0r", + "YIFY", + "LAMA", + "WORLD", + } +} + +func prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + select { + case <-ctx.Done(): + return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) + default: + } + + assets := trackers.DescriptionAssets{} + if req.Assets != nil { + assets = *req.Assets + } else { + resolvedAssets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) + } + if req.Logger != nil { + req.Logger.Warnf("trackers: PTP description assets failed: %v", err) + } + } else { + assets = resolvedAssets + } + } + + description := strings.TrimSpace(assets.Description) + if !assets.Final { + description = buildDescription(req.Meta, req.TrackerConfig, req.Runtime.DescriptionConfig(), assets) + } + if strings.TrimSpace(description) == "" && req.Logger != nil { + req.Logger.Infof("trackers: PTP preparation description empty") + } + + return trackers.DescriptionResult{ + Group: "ptp", + Description: description, + }, nil +} diff --git a/internal/trackers/impl/standalone/ptp/definition_test.go b/internal/trackers/impl/standalone/ptp/definition_test.go new file mode 100644 index 00000000..f52a7ede --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/definition_test.go @@ -0,0 +1,692 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/anacrolix/torrent/metainfo" + mkbrr "github.com/autobrr/mkbrr/torrent" + + "github.com/autobrr/upbrr/internal/config" + servicedb "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func (d *Definition) prepareDryRun(ctx context.Context, input trackers.PreparationInput) (api.TrackerDryRunEntry, error) { + input.Intent = trackers.PreparationIntentDryRun + plan, failure := trackers.PrepareAdapter(ctx, input, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, d.baseURL) + }) + if failure != nil { + return api.TrackerDryRunEntry{}, failure + } + return plan.DryRun(), nil +} + +func (d *Definition) submit(ctx context.Context, input trackers.PreparationInput) (api.UploadSummary, error) { + return uploadAt(ctx, input, d.baseURL) +} + +func TestDefinitionBuildDescriptionUsesPTPGroup(t *testing.T) { + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Group != "ptp" { + t.Fatalf("expected ptp group, got %q", result.Group) + } +} + +func TestDefinitionBuildDescriptionUsesResolvedAssetsAndMediaInfo(t *testing.T) { + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + MediaInfoTextPath: mediaInfoPath, + Options: api.UploadOptions{Screens: 1}, + }, + Assets: &trackers.DescriptionAssets{ + Description: "kept https://pixhost.to/show/encoded.png", + Screenshots: []api.ScreenshotImage{{ + Host: "pixhost", + RawURL: "https://pixhost.to/show/encoded.png", + }}, + }, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result.Description, "[mediainfo]General\nUnique ID : 123[/mediainfo]") { + t.Fatalf("expected mediainfo section, got %q", result.Description) + } + if !strings.Contains(result.Description, "kept") || !strings.Contains(result.Description, "[img]https://pixhost.to/show/encoded.png[/img]") { + t.Fatalf("expected resolved asset description, got %q", result.Description) + } + if strings.Contains(result.Description, "lostimg.cc") { + t.Fatalf("expected no stale image hosts, got %q", result.Description) + } +} + +func TestDefinitionBuildDescriptionUsesAllResolvedPixhostScreenshots(t *testing.T) { + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + screenshots := []api.ScreenshotImage{ + {Host: "pixhost", RawURL: "https://pixhost.to/1.png"}, + {Host: "pixhost", RawURL: "https://pixhost.to/2.png"}, + {Host: "pixhost", RawURL: "https://pixhost.to/3.png"}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + MediaInfoTextPath: mediaInfoPath, + Options: api.UploadOptions{Screens: 2}, + }, + Assets: &trackers.DescriptionAssets{Screenshots: screenshots}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, screenshot := range screenshots { + if !strings.Contains(result.Description, "[img]"+screenshot.RawURL+"[/img]") { + t.Fatalf("expected screenshot %q in description, got %q", screenshot.RawURL, result.Description) + } + } +} + +func TestDefinitionBuildDescriptionUsesAllowedNonPixhostRawScreenshots(t *testing.T) { + tmp := t.TempDir() + mediaInfoPath := filepath.Join(tmp, "mediainfo.txt") + if err := os.WriteFile(mediaInfoPath, []byte("General\nUnique ID : 123"), 0o600); err != nil { + t.Fatalf("write mediainfo: %v", err) + } + + screenshots := []api.ScreenshotImage{ + { + Host: "imgbb", + RawURL: "https://i.ibb.co/raw-1/source.png", + ImgURL: "https://i.ibb.co/thumb-1/source.png", + }, + { + Host: "onlyimage", + RawURL: "https://onlyimage.org/images/raw-2.png", + ImgURL: "https://onlyimage.org/images/medium-2.png", + }, + { + Host: "ptscreens", + RawURL: "https://ptscreens.com/images/raw-3.png", + ImgURL: "https://ptscreens.com/images/medium-3.png", + }, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + MediaInfoTextPath: mediaInfoPath, + Options: api.UploadOptions{Screens: 2}, + }, + Assets: &trackers.DescriptionAssets{Screenshots: screenshots}, + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, screenshot := range screenshots { + if !strings.Contains(result.Description, "[img]"+screenshot.RawURL+"[/img]") { + t.Fatalf("expected raw screenshot %q in description, got %q", screenshot.RawURL, result.Description) + } + if strings.Contains(result.Description, screenshot.ImgURL) { + t.Fatalf("expected PTP description to avoid non-raw URL %q, got %q", screenshot.ImgURL, result.Description) + } + } +} + +func TestDefinitionBuildUploadDryRunForExistingGroup(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "release.torrent") + createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ptpTorrentPath: + _ = json.NewEncoder(w).Encode(map[string]any{ + "Movies": []map[string]any{{"GroupId": "1234"}}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + entry, err := (&Definition{baseURL: server.URL}).prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + ReleaseName: "Movie.2026.1080p.BluRay.x264", + Source: "BluRay", + VideoCodec: "AVC", + Identity: api.ExternalIdentity{Category: "MOVIE", IMDBID: 1234567}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Movie", + Year: 2026, + Poster: "https://img.example/poster.jpg", + Genres: "Action", + }, + }, + }, + TrackerConfig: config.TrackerConfig{ + PTPAPIUser: "user", + PTPAPIKey: "key", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected dry-run error: %v", err) + } + if got := entry.Payload["groupid"]; got != "1234" { + t.Fatalf("expected existing group id, got %q", got) + } + if _, exists := entry.Payload["title"]; exists { + t.Fatal("did not expect new-group title field when group already exists") + } + if entry.Questionnaire != nil { + t.Fatal("did not expect questionnaire for existing group upload") + } +} + +func TestDefinitionBuildUploadDryRunForNewGroupIncludesQuestionnaire(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "release.torrent") + createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) + + entry, err := New().prepareDryRun(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + ReleaseName: "Movie.2026.1080p.BluRay.x264", + Source: "BluRay", + VideoCodec: "AVC", + Identity: api.ExternalIdentity{Category: "MOVIE"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Movie", + Year: 2026, + Poster: "https://img.example/poster.jpg", + Genres: "Action", + Overview: "Plot", + }, + }, + }, + TrackerConfig: config.TrackerConfig{}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected dry-run error: %v", err) + } + if entry.Questionnaire == nil { + t.Fatal("expected questionnaire for new group upload") + } + if got := len(entry.Questionnaire.Fields); got == 0 { + t.Fatal("expected questionnaire fields") + } + if entry.Questionnaire.Fields[0].Key != "title" { + t.Fatalf("expected first questionnaire field to be title, got %q", entry.Questionnaire.Fields[0].Key) + } + if got := entry.Payload["imdb"]; got != "0" { + t.Fatalf("expected missing IMDb dry-run payload value 0, got %q", got) + } +} + +func TestResolveUploadTorrentPathReusesPreparedPTPArtifact(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "ua.db") + sourcePath := filepath.Join(tmp, "Movie.mkv") + torrentPath := filepath.Join(tmp, "release.torrent") + createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) + + preparedMeta, err := trackers.PrepareTrackerUploadTorrent( + api.UploadSubject{SourcePath: sourcePath, TorrentPath: torrentPath}, + dbPath, + "PTP", + config.TrackerConfig{AnnounceURL: "https://please.passthepopcorn.me/passkey/announce"}, + ) + if err != nil { + t.Fatalf("prepare tracker torrent: %v", err) + } + + resolvedPath, err := resolveUploadTorrentPath(preparedMeta, dbPath) + if err != nil { + t.Fatalf("resolve upload torrent path: %v", err) + } + if resolvedPath != preparedMeta.TorrentPath { + t.Fatalf("expected prepared artifact path %q, got %q", preparedMeta.TorrentPath, resolvedPath) + } + + torrentMeta, err := metainfo.LoadFromFile(resolvedPath) + if err != nil { + t.Fatalf("load prepared artifact: %v", err) + } + if torrentMeta.Announce != "https://please.passthepopcorn.me/passkey/announce" { + t.Fatal("expected prepared announce preserved") + } + info, err := torrentMeta.UnmarshalInfo() + if err != nil { + t.Fatalf("unmarshal prepared info: %v", err) + } + if info.Source != "PTP" { + t.Fatalf("expected prepared source preserved, got %q", info.Source) + } +} + +func TestDefinitionUploadSuccess(t *testing.T) { + tmp := t.TempDir() + dbPath := newPTPAuthDB(t) + torrentPath := filepath.Join(tmp, "release.torrent") + createTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) + markTorrentWithPrivateMetadata(t, torrentPath) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.RequestURI() { + case ptpLoginPath: + if err := r.ParseForm(); err != nil { + t.Errorf("parse login: %v", err) + return + } + if r.FormValue("username") != "user" || r.FormValue("password") != "pass" { + t.Error("unexpected login credentials") + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "cookievalue", + Path: "/", + }) + _ = json.NewEncoder(w).Encode(map[string]any{ + "Result": "Ok", + "AntiCsrfToken": "csrf-token", + }) + default: + switch r.URL.Path { + case ptpUploadPath: + if err := r.ParseMultipartForm(5 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + return + } + files := r.MultipartForm.File["file_input"] + if len(files) != 1 { + t.Errorf("expected one torrent file, got %d", len(files)) + return + } + uploaded, err := files[0].Open() + if err != nil { + t.Errorf("open uploaded torrent: %v", err) + return + } + defer uploaded.Close() + payload, err := io.ReadAll(uploaded) + if err != nil { + t.Errorf("read uploaded torrent: %v", err) + return + } + uploadedMeta, err := metainfo.Load(bytes.NewReader(payload)) + if err != nil { + t.Errorf("load uploaded torrent: %v", err) + return + } + if uploadedMeta.Comment != "upbrr" { + t.Errorf("expected cleaned upload torrent comment, got %q", uploadedMeta.Comment) + return + } + if uploadedMeta.CreatedBy != "uploaded with upbrr" { + t.Errorf("expected cleaned upload torrent created-by, got %q", uploadedMeta.CreatedBy) + return + } + if uploadedMeta.Announce != "" { + t.Error("expected upload torrent announce stripped") + return + } + if r.FormValue("AntiCsrfToken") != "csrf-token" { + t.Error("expected csrf token") + return + } + if r.FormValue("title") != "Movie" { + t.Error("expected new-group title field") + return + } + http.Redirect(w, r, "/torrents.php?id=555&torrentid=666", http.StatusFound) + case ptpTorrentPath: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + default: + w.WriteHeader(http.StatusNotFound) + } + } + })) + defer server.Close() + + result, err := (&Definition{baseURL: server.URL}).submit(context.Background(), trackers.PreparationInput{ + Tracker: "PTP", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + ReleaseName: "Movie.2026.1080p.BluRay.x264", + Source: "BluRay", + VideoCodec: "AVC", + Identity: api.ExternalIdentity{Category: "MOVIE", IMDBID: 1234567}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Movie", + Year: 2026, + Poster: "https://img.example/poster.jpg", + Genres: "Action", + Overview: "Plot", + }, + }, + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + }) + if err != nil { + t.Fatalf("unexpected upload error: %v", err) + } + if result.Uploaded != 1 { + t.Fatalf("expected uploaded=1, got %d", result.Uploaded) + } + if len(result.UploadedTorrents) != 1 { + t.Fatalf("expected uploaded torrent artifact") + } + if result.UploadedTorrents[0].TorrentID != "666" { + t.Fatalf("expected torrent id 666, got %q", result.UploadedTorrents[0].TorrentID) + } + artifactPath := result.UploadedTorrents[0].TorrentPath + if strings.TrimSpace(artifactPath) == "" { + t.Fatal("expected tracker torrent path") + } + if _, err := os.Stat(artifactPath); err != nil { + t.Fatalf("expected tracker torrent file: %v", err) + } + legacyCookiePath, err := servicedb.CookiePath(dbPath, ptpCookieFile) + if err != nil { + t.Fatalf("resolve legacy PTP cookie path: %v", err) + } + if _, err := os.Stat(legacyCookiePath); !os.IsNotExist(err) { + t.Fatalf("expected no legacy PTP cookie file after upload, got err=%v", err) + } +} + +func TestLoginAndFetchAntiCsrfTokenHandles2FA(t *testing.T) { + secondLogin := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != ptpLoginPath { + w.WriteHeader(http.StatusNotFound) + return + } + if err := r.ParseForm(); err != nil { + t.Errorf("parse login: %v", err) + return + } + if r.FormValue("username") != "user" || r.FormValue("password") != "pass" || r.FormValue("passkey") != "passkey" || r.FormValue("keeplogged") != "1" { + t.Error("unexpected login form") + return + } + if r.FormValue("TfaCode") == "" { + _ = json.NewEncoder(w).Encode(map[string]any{"Result": "TfaRequired"}) + return + } + secondLogin = true + if r.FormValue("TfaType") != "normal" { + t.Errorf("expected TfaType normal, got %q", r.FormValue("TfaType")) + return + } + code := r.FormValue("TfaCode") + if len(code) != 6 || strings.Trim(code, "0123456789") != "" { + t.Errorf("expected six digit TfaCode, got %q", code) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "cookievalue", + Path: "/", + }) + _ = json.NewEncoder(w).Encode(map[string]any{ + "Result": "Ok", + "AntiCsrfToken": "csrf-token", + }) + })) + defer server.Close() + + dbPath := newPTPAuthDB(t) + client, token, err := loginAndFetchAntiCsrfToken(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + OTPURI: "otpauth://totp/PTP:user?secret=JBSWY3DPEHPK3PXP&issuer=PTP", + }, dbPath, server.URL, api.NopLogger{}, api.TrackerAuthLoginRequest{}) + if err != nil { + t.Fatalf("login: %v", err) + } + if token != "csrf-token" { + t.Fatalf("expected csrf token, got %q", token) + } + if client == nil { + t.Fatal("expected authenticated client") + } + if !secondLogin { + t.Fatal("expected second 2FA login request") + } +} + +func TestRehostPosterToSelectedHostUploadsPoster(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "ua.db") + sourcePath := filepath.Join(tmp, "Movie.mkv") + if err := os.WriteFile(sourcePath, []byte("movie"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + + posterServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("poster-bytes")) + })) + defer posterServer.Close() + originalClientFactory := newPosterHTTPClient + newPosterHTTPClient = posterServer.Client + t.Cleanup(func() { + newPosterHTTPClient = originalClientFactory + }) + + images := &stubPTPImageHosting{ + uploaded: []api.UploadedImageLink{{ + Host: "pixhost", + RawURL: "https://pixhost.to/rehosted.png", + }}, + } + got := rehostPosterToSelectedHost(context.Background(), trackers.PreparationInput{ + Meta: api.UploadSubject{ + SourcePath: sourcePath, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + Logger: api.NopLogger{}, + SelectedImageHost: "pixhost", + UploadImages: func(ctx context.Context, uploaded []api.ScreenshotImage) ([]api.UploadedImageLink, error) { + return images.Upload(ctx, api.ImageHostingSubject{}, "pixhost", "global", uploaded) + }, + }, posterServer.URL+"/poster") + + if got != "https://pixhost.to/rehosted.png" { + t.Fatalf("expected rehosted poster URL, got %q", got) + } + if images.host != "pixhost" { + t.Fatalf("expected pixhost upload host, got %q", images.host) + } + if len(images.images) != 1 { + t.Fatalf("expected one uploaded poster, got %d", len(images.images)) + } + posterPath := images.images[0].Path + if filepath.Base(posterPath) != "PTP_POSTER.png" { + t.Fatalf("expected content-type poster extension, got %q", filepath.Base(posterPath)) + } + body, err := os.ReadFile(posterPath) + if err != nil { + t.Fatalf("read poster: %v", err) + } + if string(body) != "poster-bytes" { + t.Fatalf("unexpected poster body %q", string(body)) + } +} + +func TestRehostPosterToSelectedHostRejectsLoopbackPoster(t *testing.T) { + tmp := t.TempDir() + sourcePath := filepath.Join(tmp, "Movie.mkv") + if err := os.WriteFile(sourcePath, []byte("movie"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + posterServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("poster-bytes")) + })) + defer posterServer.Close() + + images := &stubPTPImageHosting{ + uploaded: []api.UploadedImageLink{{ + Host: "pixhost", + RawURL: "https://pixhost.to/rehosted.png", + }}, + } + got := rehostPosterToSelectedHost(context.Background(), trackers.PreparationInput{ + Meta: api.UploadSubject{ + SourcePath: sourcePath, + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: filepath.Join(tmp, "ua.db")}}), + Logger: api.NopLogger{}, + SelectedImageHost: "pixhost", + UploadImages: func(ctx context.Context, uploaded []api.ScreenshotImage) ([]api.UploadedImageLink, error) { + return images.Upload(ctx, api.ImageHostingSubject{}, "pixhost", "global", uploaded) + }, + }, posterServer.URL+"/poster") + + if got != posterServer.URL+"/poster" { + t.Fatalf("expected original poster URL after blocked rehost, got %q", got) + } + if len(images.images) != 0 { + t.Fatalf("expected no upload for blocked loopback poster") + } +} + +func TestIsPublicPosterIPRejectsPrivateRanges(t *testing.T) { + t.Parallel() + + for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.20", "169.254.1.1", "::1", "fc00::1"} { + if isPublicPosterIP(netip.MustParseAddr(value)) { + t.Fatalf("expected %s to be rejected", value) + } + } + if !isPublicPosterIP(netip.MustParseAddr("93.184.216.34")) { + t.Fatal("expected public address to be accepted") + } +} + +func TestRehostPosterToSelectedHostSkipsSelectedHost(t *testing.T) { + images := &stubPTPImageHosting{} + got := rehostPosterToSelectedHost(context.Background(), trackers.PreparationInput{ + SelectedImageHost: "pixhost", + UploadImages: func(ctx context.Context, uploaded []api.ScreenshotImage) ([]api.UploadedImageLink, error) { + return images.Upload(ctx, api.ImageHostingSubject{}, "pixhost", "global", uploaded) + }, + }, "https://pixhost.to/existing.jpg") + if got != "https://pixhost.to/existing.jpg" { + t.Fatalf("expected original poster URL, got %q", got) + } + if len(images.images) != 0 { + t.Fatalf("expected no upload for selected host poster") + } +} + +type stubPTPImageHosting struct { + host string + scope string + images []api.ScreenshotImage + uploaded []api.UploadedImageLink +} + +func (s *stubPTPImageHosting) ListCandidates(context.Context, api.ImageHostingSubject) ([]api.ScreenshotImage, error) { + return nil, nil +} + +func (s *stubPTPImageHosting) Upload(_ context.Context, _ api.ImageHostingSubject, host string, usageScope string, images []api.ScreenshotImage) ([]api.UploadedImageLink, error) { + s.host = host + s.scope = usageScope + s.images = append([]api.ScreenshotImage(nil), images...) + return s.uploaded, nil +} + +func createTestTorrent(t *testing.T, sourcePath string, torrentPath string) { + t.Helper() + + if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + _, err := mkbrr.Create(mkbrr.CreateOptions{ + Path: sourcePath, + OutputPath: torrentPath, + IsPrivate: true, + }) + if err != nil { + t.Fatalf("create torrent: %v", err) + } +} + +func markTorrentWithPrivateMetadata(t *testing.T, torrentPath string) { + t.Helper() + + torrentMeta, err := metainfo.LoadFromFile(torrentPath) + if err != nil { + t.Fatalf("load torrent: %v", err) + } + torrentMeta.Announce = "https://private.example/passkey/announce" + torrentMeta.Comment = "Created by Upload Assistant https://private.example/download/1" + file, err := os.Create(torrentPath) + if err != nil { + t.Fatalf("rewrite torrent: %v", err) + } + defer file.Close() + if err := torrentMeta.Write(file); err != nil { + t.Fatalf("write torrent: %v", err) + } +} diff --git a/internal/trackers/impl/standalone/ptp/dupe.go b/internal/trackers/impl/standalone/ptp/dupe.go new file mode 100644 index 00000000..71eb2f28 --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/dupe.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// newDuplicateAdapterAt returns a duplicate-search adapter bound to one +// immutable dependency set and base URL. +func newDuplicateAdapterAt(deps dupe.Dependencies, baseURL string) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: strings.TrimRight(baseURL, "/") + ptpTorrentPath, + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiUser, apiKey := ptpAPIKeys(s.cfg) + if apiUser == "" || apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing ApiUser/ApiKey for tracker", nil) + } + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb id for PTP dupe search", nil) + } + headers := map[string]string{"ApiUser": apiUser, "ApiKey": apiKey} + groupPayload, err := s.get(ctx, url.Values{"imdb": {"tt" + strconv.Itoa(meta.Identity.IMDBID)}}, headers) + if err != nil || len(groupPayload) == 0 { + return dupe.Failed(dupe.FailureRequest, "PTP group search failed", err) + } + groupID := ptpGroupID(groupPayload) + if groupID == "" { + return dupe.Resolved(nil, nil) + } + payload, err := s.get(ctx, url.Values{"id": {groupID}}, headers) + if err != nil || len(payload) == 0 { + return dupe.Failed(dupe.FailureRequest, "PTP torrent search failed", err) + } + return dupe.Resolved(ptpDupeEntries(payload, meta.Release.Resolution, strings.TrimSuffix(s.endpoint, ptpTorrentPath)), nil) +} + +func (s *dupeSearcher) get(ctx context.Context, params url.Values, headers map[string]string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.endpoint, nil) + if err != nil { + return nil, fmt.Errorf("ptp dupe request: %w", err) + } + req.URL.RawQuery = params.Encode() + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := s.http.Do(req) + if err != nil { + return nil, fmt.Errorf("ptp dupe request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, strconv.ErrSyntax + } + var payload map[string]any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("ptp dupe decode: %w", err) + } + return payload, nil +} + +func ptpAPIKeys(cfg config.Config) (string, string) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "PTP") { + return strings.TrimSpace(entry.PTPAPIUser), strings.TrimSpace(entry.PTPAPIKey) + } + } + return "", "" +} + +func ptpGroupID(payload map[string]any) string { + if movies, ok := payload["Movies"].([]any); ok && len(movies) > 0 { + if movie, ok := movies[0].(map[string]any); ok { + return ptpString(movie["GroupId"]) + } + } + return ptpString(payload["GroupId"]) +} + +func ptpDupeEntries(payload map[string]any, resolution string, baseURL string) []api.DupeEntry { + quality := "High Definition" + res := strings.ToLower(strings.TrimSpace(resolution)) + if res == "480p" || res == "480i" || res == "576p" || res == "576i" || res == "sd" { + quality = "Standard Definition" + } else if strings.Contains(res, "2160") || strings.Contains(res, "4320") || strings.Contains(res, "8640") { + quality = "Ultra High Definition" + } + torrents, _ := payload["Torrents"].([]any) + entries := make([]api.DupeEntry, 0, len(torrents)) + for _, raw := range torrents { + item, ok := raw.(map[string]any) + if !ok || ptpString(item["Quality"]) != "" && !strings.EqualFold(ptpString(item["Quality"]), quality) { + continue + } + id := ptpString(item["Id"]) + entries = append(entries, api.DupeEntry{ + Name: strings.TrimSpace("[" + ptpString(item["Resolution"]) + "] " + ptpString(item["ReleaseName"])), + ID: id, + Link: strings.TrimRight(baseURL, "/") + ptpTorrentPath + "?torrentid=" + id, + }) + } + return entries +} + +func ptpString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return "" + } +} diff --git a/internal/trackers/impl/standalone/ptp/profile.go b/internal/trackers/impl/standalone/ptp/profile.go new file mode 100644 index 00000000..ec9c542a --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/profile.go @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "context" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns PTP identity, preparation, dupe, auth, bans, and policies. +// Its missing-IMDb metadata result is advisory and does not block upload. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "PTP", + BaseURL: ptpBaseURL, + DescriptionGroup: "ptp", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: func(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, req, ptpBaseURL) + }, + NewDuplicateAdapter: func(deps dupe.Dependencies) dupe.Adapter { return newDuplicateAdapterAt(deps, ptpBaseURL) }, + Rules: &trackers.RuleSet{RequireMovieUnlessTVPack: true}, + BannedGroups: bannedGroups(), + DataPolicy: &trackers.DataLookupPolicy{Cooldown: time.Minute}, + MetadataPolicy: &trackers.TrackerMetadataPolicy{Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldIMDBIDOnly}, + Disposition: api.RuleDispositionAdvisory, + }}}, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "PTP"}, + ImageHostPolicy: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"pixhost", "imgbb", "onlyimage", "ptscreens", "passtheimage"}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{ptpBaseURL}, + CommentURLPatterns: []string{ptpBaseURL}, + DetailIDPattern: "torrentid=(\\d+)", + }, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "PTP", + DisplayName: "PTP", + AuthKind: "cookies_login_manual_2fa", + SupportsCookieFile: true, + SupportsLogin: true, + SupportsAutoLogin: true, + SupportsTOTP: true, + SupportsManual2FA: true, + }, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, login, ptpBaseURL) + }, + AuthPolicy: &trackers.AuthPolicy{LoginRequiresAnnounceURL: true}, + } +} + +// Definition extends the shared standalone definition with PTP data lookup and testable endpoints. +type Definition struct { + *standalone.Definition + baseURL string +} + +// New returns a fresh PTP definition from its tracker-local profile. +func New() *Definition { + return &Definition{Definition: standalone.MustNew(Profile()), baseURL: ptpBaseURL} +} diff --git a/internal/trackers/impl/standalone/ptp/upload.go b/internal/trackers/impl/standalone/ptp/upload.go new file mode 100644 index 00000000..4e6f36cf --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/upload.go @@ -0,0 +1,1619 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" //nolint:gosec // TOTP interoperability requires SHA-1. + "encoding/base32" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "mime/multipart" + "net" + "net/http" + "net/http/cookiejar" + "net/netip" + "net/url" + "os" + "path" //nolint:depguard // Reads poster URL path extension, not local filesystem extension. + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + imagehost "github.com/autobrr/upbrr/internal/imagehosting/host" + "github.com/autobrr/upbrr/internal/metadata/metautil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + ptpBaseURL = "https://passthepopcorn.me" + ptpUploadPath = "/upload.php" + ptpTorrentPath = "/torrents.php" + ptpLoginPath = "/ajax.php?action=login" + ptpCookieFile = "PTP.json" + ptpUserAgent = "upbrr" +) + +var ( + ptpAntiCsrfPattern = regexp.MustCompile(`data-AntiCsrfToken="([^"]+)"`) + ptpSuccessPattern = regexp.MustCompile(`torrents\.php\?id=(\d+)&torrentid=(\d+)`) + newPosterHTTPClient = newPublicPosterHTTPClient + reservedPosterPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + } +) + +// ErrSubmitted2FARejected marks a PTP failure after a submitted manual 2FA code +// reached the tracker and was rejected. +var ErrSubmitted2FARejected = trackers.ErrSubmitted2FARejected + +type uploadState struct { + baseURL string + uploadURL string + announceURL string + client *http.Client + groupID string + torrentPath string + description string + fields map[string]string +} + +func uploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (api.UploadSummary, error) { + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, baseURL) + }) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTP submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (trackers.PreparedOperation, error) { + state, err := prepareUploadStateAt(ctx, req, req.Intent != trackers.PreparationIntentUpload, baseURL) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state, req.Meta) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + + body, contentType, err := buildMultipartPayload(state.fields, state.torrentPath, "file_input") + if err != nil { + return trackers.PreparedOperation{}, err + } + trackerTorrentPath, err := resolveTrackerTorrentPath(req.Meta, req.Runtime.DBPath, "PTP") + if err != nil { + return trackers.PreparedOperation{}, err + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType, trackerTorrentPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, + trackerTorrentPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTP request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", ptpUserAgent) + + resp, err := state.client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTP upload request: %w", err) + } + defer resp.Body.Close() + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + _, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTP read upload response: %w", err) + } + if matches := ptpSuccessPattern.FindStringSubmatch(finalURL); len(matches) == 3 { + groupID := strings.TrimSpace(matches[1]) + torrentID := strings.TrimSpace(matches[2]) + torrentURL := strings.TrimRight(state.baseURL, "/") + "/torrents.php?id=" + url.QueryEscape(groupID) + "&torrentid=" + url.QueryEscape(torrentID) + if err := trackers.WritePersonalizedTorrent(state.torrentPath, trackerTorrentPath, state.announceURL, torrentURL, "PTP"); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTP write torrent artifact: %w", err) + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "PTP", + TorrentID: torrentID, + TorrentURL: torrentURL, + DownloadURL: torrentURL, + TorrentPath: trackerTorrentPath, + }}, + }, nil + } + + failurePath := "" + if pathValue, pathErr := resolveFailurePath(req.Meta, req.Runtime.DBPath); pathErr == nil { + failurePath = pathValue + redactedBody := []byte(redaction.RedactValue(string(responsePreview), nil)) + _ = os.WriteFile(failurePath, redactedBody, 0o600) + } + errText := commonhttp.RedactErrorDetail(extractAlertError(string(responsePreview))) + if errText == "" { + errText = commonhttp.ExtractHTTPErrorDetail(responsePreview) + } + if errText == "" { + errText = "upload failed" + } + if failurePath != "" { + return api.UploadSummary{}, fmt.Errorf( + "trackers: PTP upload failed status=%d url=%s error=%s failure=%s", + resp.StatusCode, + commonhttp.RedactErrorDetail(finalURL), + compactError(errText), + failurePath, + ) + } + return api.UploadSummary{}, fmt.Errorf( + "trackers: PTP upload failed status=%d url=%s error=%s", + resp.StatusCode, + commonhttp.RedactErrorDetail(finalURL), + compactError(errText), + ) +} + +func buildUploadPreview(state uploadState, meta api.UploadSubject) api.TrackerDryRunEntry { + message := "dry-run payload generated" + if state.groupID != "" { + message += " for existing group" + } else { + message += " for new group" + } + fields := maps.Clone(state.fields) + if _, ok := fields["AntiCsrfToken"]; ok { + fields["AntiCsrfToken"] = "[redacted]" + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "PTP", + ReadyMessage: message, + ReleaseName: resolveUploadName(meta), + DescriptionGroup: "ptp", + Description: state.description, + Endpoint: state.uploadURL, + Payload: fields, + Files: []api.TrackerDryRunFile{{ + Field: "file_input", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + Questionnaire: buildQuestionnaire(meta, state.groupID), + }) +} + +func prepareUploadStateAt(ctx context.Context, req trackers.PreparationInput, dryRun bool, baseURL string) (uploadState, error) { + announceURL := normalizedAnnounceURL(req.TrackerConfig.AnnounceURL) + torrentPath, err := resolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: PTP resolve upload torrent: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, req.TrackerConfig, req.Runtime.DescriptionConfig(), assets) + groupID, err := lookupGroupID(ctx, baseURL, req.TrackerConfig, req.Meta) + if err != nil { + return uploadState{}, err + } + answers := standalone.QuestionnaireAnswers(req.Meta, "PTP") + poster := metautil.FirstNonEmptyTrimmed(answers["poster"], resolvePoster(req.Meta)) + if !dryRun { + poster = rehostPosterToSelectedHost(ctx, req, poster) + } + fields, err := buildUploadFields(req.Meta, description, groupID, answers, poster) + if err != nil { + return uploadState{}, err + } + fields["AntiCsrfToken"] = "dry-run-token" + + var client *http.Client + if !dryRun { + client, fields["AntiCsrfToken"], err = resolveSession(ctx, req.TrackerConfig, req.Runtime.DBPath, baseURL, req.Logger) + if err != nil { + return uploadState{}, err + } + } + + return uploadState{ + baseURL: baseURL, + uploadURL: baseURL + ptpUploadPath, + announceURL: announceURL, + client: client, + groupID: groupID, + torrentPath: torrentPath, + description: description, + fields: fields, + }, nil +} + +func resolveUploadTorrentPath(meta api.UploadSubject, dbPath string) (string, error) { + preparedPath, err := trackers.ResolveTrackerTorrentArtifactPath(meta, dbPath, "PTP") + if err == nil && strings.TrimSpace(meta.TorrentPath) == preparedPath { + if info, statErr := os.Stat(preparedPath); statErr == nil && !info.IsDir() { + return preparedPath, nil + } + } + path, err := trackers.ResolveUploadTorrentPath(meta, dbPath) + if err != nil { + return "", fmt.Errorf("trackers: resolve upload torrent path: %w", err) + } + return path, nil +} + +func buildDescription(meta api.UploadSubject, trackerConfig config.TrackerConfig, appConfig config.Config, assets trackers.DescriptionAssets) string { + baseDescription := strings.TrimSpace(assets.Description) + if assets.Final { + return baseDescription + } + if baseDescription != "" { + report := CleanDescription(baseDescription, meta.DiscType) + baseDescription = strings.TrimSpace(report.Description) + } + + var sections []string + if mediaSection, err := buildMediaSection(meta, appConfig.MainSettings.DBPath); err == nil && mediaSection != "" { + sections = append(sections, mediaSection) + } + if strings.TrimSpace(baseDescription) != "" { + sections = append(sections, convertDescription(baseDescription)) + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && strings.TrimSpace(meta.ServiceLongName) != "" && trackerConfig.AddWebSourceToDesc { + sections = append( + sections, + fmt.Sprintf("[quote][align=center]This release is sourced from %s[/align][/quote]", strings.TrimSpace(meta.ServiceLongName)), + ) + } + if shots := buildScreenshotSection(meta, assets.Screenshots); shots != "" { + sections = append(sections, shots) + } + return strings.TrimSpace(strings.Join(sections, "\n\n")) +} + +func buildMediaSection(meta api.UploadSubject, dbPath string) (string, error) { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + text, err := readBDSummary(meta, dbPath) + if err != nil { + return "", err + } + if strings.TrimSpace(text) == "" { + return "", nil + } + return "[mediainfo]" + strings.TrimSpace(text) + "[/mediainfo]", nil + default: + text, err := readTextFile(strings.TrimSpace(meta.MediaInfoTextPath)) + if err != nil { + return "", err + } + if strings.TrimSpace(text) == "" { + return "", nil + } + return "[mediainfo]" + strings.TrimSpace(text) + "[/mediainfo]", nil + } +} + +func buildScreenshotSection(meta api.UploadSubject, screenshots []api.ScreenshotImage) string { + if len(screenshots) == 0 { + return "" + } + minimum := meta.Options.Screens + if minimum <= 0 { + minimum = 2 + } + if requiresMinimumTwoScreens(meta) && minimum < 2 { + minimum = 2 + } + allowed := make([]string, 0, len(screenshots)) + for _, screenshot := range screenshots { + rawURL := strings.TrimSpace(screenshot.RawURL) + if rawURL == "" { + rawURL = strings.TrimSpace(screenshot.ImgURL) + } + if rawURL == "" { + continue + } + host := strings.ToLower(strings.TrimSpace(screenshot.Host)) + if host == "" { + host = imagehost.ExtractHost(rawURL) + } + if !isPTPImageHost(host) { + continue + } + allowed = append(allowed, "[img]"+rawURL+"[/img]") + } + if len(allowed) < minimum { + return "" + } + return strings.Join(allowed, "\n") +} + +func isPTPImageHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "pixhost", "imgbb", "onlyimage", "ptscreens": + return true + default: + return false + } +} + +func requiresMinimumTwoScreens(meta api.UploadSubject) bool { + if len(meta.FileList) > 1 { + return true + } + return strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") +} + +func convertDescription(value string) string { + replacer := strings.NewReplacer( + "[spoiler", "[hide", + "[/spoiler]", "[/hide]", + "[center]", "[align=center]", + "[/center]", "[/align]", + "[left]", "[align=left]", + "[/left]", "[/align]", + "[right]", "[align=right]", + "[/right]", "[/align]", + "[h1]", "[u][b]", + "[/h1]", "[/b][/u]", + "[h2]", "[u][b]", + "[/h2]", "[/b][/u]", + "[h3]", "[u][b]", + "[/h3]", "[/b][/u]", + ) + return replacer.Replace(strings.TrimSpace(value)) +} + +func lookupGroupID(ctx context.Context, baseURL string, trackerConfig config.TrackerConfig, meta api.UploadSubject) (string, error) { + apiUser := strings.TrimSpace(trackerConfig.PTPAPIUser) + apiKey := strings.TrimSpace(trackerConfig.PTPAPIKey) + if apiUser == "" || apiKey == "" || meta.Identity.IMDBID == 0 { + return "", nil + } + headers := map[string]string{ + "ApiUser": apiUser, + "ApiKey": apiKey, + "User-Agent": ptpUserAgent, + } + values := url.Values{} + values.Set("imdb", fmt.Sprintf("tt%07d", meta.Identity.IMDBID)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+ptpTorrentPath+"?"+values.Encode(), nil) + if err != nil { + return "", fmt.Errorf("trackers: PTP build group lookup request: %w", err) + } + for key, value := range headers { + httpReq.Header.Set(key, value) + } + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", nil + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil + } + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", nil + } + if movies, ok := payload["Movies"].([]any); ok && len(movies) > 0 { + if movie, ok := movies[0].(map[string]any); ok { + if groupID := stringFromAny(movie["GroupId"]); groupID != "" { + return groupID, nil + } + } + } + return stringFromAny(payload["GroupId"]), nil +} + +func rehostPosterToSelectedHost(ctx context.Context, req trackers.PreparationInput, imageURL string) string { + trimmedURL := strings.TrimSpace(imageURL) + if trimmedURL == "" { + return "" + } + if req.UploadImages == nil { + return trimmedURL + } + if req.Meta.ImageHostOverrides.SkipUpload != nil && *req.Meta.ImageHostOverrides.SkipUpload { + return trimmedURL + } + + selectedHost := strings.ToLower(strings.TrimSpace(req.SelectedImageHost)) + if selectedHost == "" { + return trimmedURL + } + if strings.EqualFold(strings.TrimSpace(imagehost.ExtractHost(trimmedURL)), selectedHost) { + return trimmedURL + } + + posterPath, err := downloadPoster(ctx, req.Meta, req.Runtime.DBPath, trimmedURL) + if err != nil { + logPosterRehostFailure(req.Logger, selectedHost, err) + return trimmedURL + } + uploaded, err := req.UploadImages(ctx, []api.ScreenshotImage{{Path: posterPath}}) + if err != nil { + logPosterRehostFailure(req.Logger, selectedHost, err) + return trimmedURL + } + if len(uploaded) == 0 { + logPosterRehostFailure(req.Logger, selectedHost, errors.New("upload returned no links")) + return trimmedURL + } + uploadedURL := metautil.FirstNonEmptyTrimmed(uploaded[0].RawURL, uploaded[0].ImgURL, uploaded[0].WebURL) + if strings.TrimSpace(uploadedURL) == "" { + logPosterRehostFailure(req.Logger, selectedHost, errors.New("upload returned blank link")) + return trimmedURL + } + if req.Logger != nil { + req.Logger.Infof("trackers: PTP poster rehosted to %s", selectedHost) + } + return strings.TrimSpace(uploadedURL) +} + +func downloadPoster(ctx context.Context, meta api.UploadSubject, dbPath string, imageURL string) (string, error) { + if err := validatePosterURL(imageURL); err != nil { + return "", err + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return "", fmt.Errorf("poster request: %w", err) + } + httpReq.Header.Set("User-Agent", ptpUserAgent) + + client := newPosterHTTPClient() + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("poster download: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("poster download status=%d", resp.StatusCode) + } + + const maxPosterBytes = 25 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxPosterBytes+1)) + if err != nil { + return "", fmt.Errorf("poster read: %w", err) + } + if len(body) == 0 { + return "", errors.New("poster download returned empty body") + } + if len(body) > maxPosterBytes { + return "", errors.New("poster exceeds maximum size") + } + + posterPath := filepath.Join(tmpDir, "PTP_POSTER"+posterExtension(imageURL, resp.Header.Get("Content-Type"))) + if err := os.WriteFile(posterPath, body, 0o600); err != nil { + return "", fmt.Errorf("poster write: %w", err) + } + return posterPath, nil +} + +func posterExtension(imageURL string, contentType string) string { + if parsed, err := url.Parse(imageURL); err == nil { + switch ext := strings.ToLower(path.Ext(parsed.Path)); ext { + case ".jpg", ".jpeg", ".png", ".webp": + return ext + } + } + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + switch mediaType { + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/jpeg": + return ".jpg" + default: + return ".jpg" + } +} + +func validatePosterURL(rawURL string) error { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("poster URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.New("poster URL must use http or https") + } + if strings.TrimSpace(parsed.Hostname()) == "" { + return errors.New("poster URL host is required") + } + return nil +} + +func newPublicPosterHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 30 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("poster dial address: %w", err) + } + target, err := resolvePublicPosterAddress(ctx, host, port) + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, target) + }, + } + return &http.Client{Timeout: 30 * time.Second, Transport: transport} +} + +func resolvePublicPosterAddress(ctx context.Context, host string, port string) (string, error) { + if ip := net.ParseIP(host); ip != nil { + addr, ok := netip.AddrFromSlice(ip) + if !ok || !isPublicPosterIP(addr.Unmap()) { + return "", fmt.Errorf("poster host %q resolves to non-public IP", host) + } + return net.JoinHostPort(addr.String(), port), nil + } + resolved, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return "", fmt.Errorf("poster DNS lookup %q: %w", host, err) + } + for _, candidate := range resolved { + addr, ok := netip.AddrFromSlice(candidate.IP) + if !ok { + continue + } + addr = addr.Unmap() + if isPublicPosterIP(addr) { + return net.JoinHostPort(addr.String(), port), nil + } + } + return "", fmt.Errorf("poster host %q has no public IP addresses", host) +} + +func isPublicPosterIP(ip netip.Addr) bool { + if !ip.IsValid() || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() { + return false + } + return !ipInPrefixes(ip, reservedPosterPrefixes) +} + +func ipInPrefixes(ip netip.Addr, prefixes []netip.Prefix) bool { + for _, prefix := range prefixes { + if prefix.Contains(ip) { + return true + } + } + return false +} + +func logPosterRehostFailure(logger api.Logger, host string, err error) { + if logger == nil || err == nil { + return + } + if strings.TrimSpace(host) == "" { + logger.Warnf("trackers: PTP poster rehost failed: %v", err) + return + } + logger.Warnf("trackers: PTP poster rehost to %s failed: %v", strings.TrimSpace(host), err) +} + +func buildUploadFields(meta api.UploadSubject, description string, groupID string, answers map[string]string, poster string) (map[string]string, error) { + resolution, otherResolution := resolveResolution(meta) + fields := map[string]string{ + "submit": "true", + "remaster_year": "", + "remaster_title": resolveRemasterTitle(meta), + "type": resolveType(meta), + "codec": "Other", + "other_codec": resolveCodec(meta), + "container": "Other", + "other_container": resolveContainer(meta), + "resolution": resolution, + "source": "Other", + "other_source": resolveSource(meta.Source), + "release_desc": description, + "nfo_text": "", + "subtitles[]": joinInts(resolveSubtitles(meta)), + "trumpable[]": "", + } + if resolution == "Other" && otherResolution != "" { + fields["other_resolution"] = otherResolution + } + if fields["remaster_title"] != "" { + fields["remaster"] = "on" + } + if meta.Scene { + fields["scene"] = "on" + } + if meta.PersonalRelease { + fields["internalrip"] = "on" + } + if meta.Identity.IMDBID == 0 { + fields["imdb"] = "0" + } else { + fields["imdb"] = fmt.Sprintf("%07d", meta.Identity.IMDBID) + } + if groupID != "" { + fields["groupid"] = groupID + return fields, nil + } + + title, year := resolveGroupTitleYear(meta) + title = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["title"]), title) + year = metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["year"]), year) + if strings.TrimSpace(title) == "" { + return nil, errors.New("trackers: PTP missing title for new group upload") + } + fields["title"] = title + fields["year"] = year + fields["image"] = strings.TrimSpace(poster) + fields["tags"] = metautil.FirstNonEmptyTrimmed(answers["tags"], resolveTags(meta)) + fields["album_desc"] = metautil.FirstNonEmptyTrimmed(answers["album_desc"], resolveOverview(meta)) + fields["trailer"] = metautil.FirstNonEmptyTrimmed(answers["trailer"], resolveTrailer(meta)) + directors := resolveDirectors(meta) + if len(directors) > 0 { + fields["artist[]"] = strings.Join(directors, "\n") + fields["importance[]"] = "1" + } + if fields["image"] == "" { + return nil, errors.New("trackers: PTP missing poster for new group upload") + } + return fields, nil +} + +func resolveSession(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, baseURL string, logger api.Logger) (*http.Client, string, error) { + return resolveSessionLogin(ctx, trackerConfig, dbPath, baseURL, logger, api.TrackerAuthLoginRequest{}) +} + +func resolveSessionLogin( + ctx context.Context, + trackerConfig config.TrackerConfig, + dbPath string, + baseURL string, + logger api.Logger, + login api.TrackerAuthLoginRequest, +) (*http.Client, string, error) { + if logger == nil { + logger = api.NopLogger{} + } + + cookies, err := loadCookies(ctx, dbPath) + if err == nil && len(cookies) > 0 { + client, token, tokenErr := fetchAntiCsrfToken(ctx, baseURL, cookies) + if tokenErr == nil { + return client, token, nil + } + if strings.TrimSpace(trackerConfig.Username) == "" || strings.TrimSpace(trackerConfig.Password) == "" || + strings.TrimSpace(normalizedAnnounceURL(trackerConfig.AnnounceURL)) == "" { + return nil, "", tokenErr + } + } + return loginAndFetchAntiCsrfToken(ctx, trackerConfig, dbPath, baseURL, logger, login) +} + +// ResolveSessionForTrackerAuth validates PTP stored cookies or logs in with +// configured credentials. Credential login must produce an anti-CSRF token +// before refreshed cookies are persisted. +func ResolveSessionForTrackerAuth(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string) error { + return resolveSessionForTrackerAuthAt(ctx, trackerConfig, dbPath, ptpBaseURL) +} + +func resolveSessionForTrackerAuthAt(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, baseURL string) error { + return resolveSessionForTrackerAuthLoginAt(ctx, trackerConfig, dbPath, api.TrackerAuthLoginRequest{}, baseURL) +} + +// ResolveSessionForTrackerAuthLogin validates PTP stored cookies or logs in +// with configured credentials. When PTP requires 2FA, login.Code is used before +// falling back to the configured OTP URI; if neither yields a code, the error +// is returned before stored cookies are replaced. A rejected submitted code +// returns [ErrSubmitted2FARejected] with the login-failed error before refreshed +// cookies are persisted. +func ResolveSessionForTrackerAuthLogin(ctx context.Context, trackerConfig config.TrackerConfig, dbPath string, login api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, trackerConfig, dbPath, login, ptpBaseURL) +} + +func resolveSessionForTrackerAuthLoginAt( + ctx context.Context, + trackerConfig config.TrackerConfig, + dbPath string, + login api.TrackerAuthLoginRequest, + baseURL string, +) error { + _, _, err := resolveSessionLogin(ctx, trackerConfig, dbPath, baseURL, api.NopLogger{}, login) + return err +} + +func fetchAntiCsrfToken(ctx context.Context, baseURL string, cookies map[string]string) (*http.Client, string, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP create session cookie jar: %w", err) + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP parse base URL: %w", err) + } + jarCookies := make([]*http.Cookie, 0, len(cookies)) + for name, value := range cookies { + if strings.TrimSpace(name) == "" || strings.TrimSpace(value) == "" { + continue + } + // #nosec G124 -- Outbound tracker jar cookie mirrors configured PTP session values. + jarCookies = append(jarCookies, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Domain: parsed.Hostname(), + }) + } + jar.SetCookies(parsed, jarCookies) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + token, err := requestAntiCsrfToken(ctx, client, baseURL) + if err != nil { + return nil, "", err + } + return client, token, nil +} + +func loginAndFetchAntiCsrfToken( + ctx context.Context, + trackerConfig config.TrackerConfig, + dbPath string, + baseURL string, + _ api.Logger, + login api.TrackerAuthLoginRequest, +) (*http.Client, string, error) { + username := strings.TrimSpace(trackerConfig.Username) + password := strings.TrimSpace(trackerConfig.Password) + announceURL := normalizedAnnounceURL(trackerConfig.AnnounceURL) + if username == "" || password == "" || announceURL == "" { + return nil, "", errors.New("trackers: PTP requires username, password, and announce_url") + } + passkey, err := passkeyFromAnnounce(announceURL) + if err != nil { + return nil, "", err + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP create login cookie jar: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + form := url.Values{ + "username": {username}, + "password": {password}, + "passkey": {passkey}, + "keeplogged": {"1"}, + } + loginURL := strings.TrimRight(baseURL, "/") + ptpLoginPath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP build login request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("User-Agent", ptpUserAgent) + resp, err := client.Do(httpReq) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP login request: %w", err) + } + defer resp.Body.Close() + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, "", fmt.Errorf("trackers: PTP login decode: %w", err) + } + switch strings.TrimSpace(stringFromAny(payload["Result"])) { + case "Ok": + case "TfaRequired": + code, codeErr := resolvePTP2FACode(trackerConfig, login) + if codeErr != nil { + return nil, "", fmt.Errorf("trackers: PTP 2FA required: %w", codeErr) + } + form.Set("TfaType", "normal") + form.Set("TfaCode", code) + httpReq, err = http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP build 2FA request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("User-Agent", ptpUserAgent) + resp, err = client.Do(httpReq) + if err != nil { + return nil, "", fmt.Errorf("trackers: PTP 2FA request: %w", err) + } + defer resp.Body.Close() + payload = map[string]any{} + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, "", fmt.Errorf("trackers: PTP 2FA decode: %w", err) + } + if strings.TrimSpace(stringFromAny(payload["Result"])) != "Ok" { + return nil, "", fmt.Errorf("trackers: PTP login failed: %w", ErrSubmitted2FARejected) + } + default: + return nil, "", errors.New("trackers: PTP login failed") + } + + token := strings.TrimSpace(stringFromAny(payload["AntiCsrfToken"])) + if token == "" { + return nil, "", errors.New("trackers: PTP login missing anti csrf token") + } + if err := saveCookies(ctx, dbPath, client, baseURL); err != nil { + return nil, "", fmt.Errorf("trackers: PTP persist login cookies: %w", err) + } + return client, token, nil +} + +// resolvePTP2FACode prefers a submitted manual code so users can continue a +// browser-visible challenge when no reusable OTP URI is configured. +func resolvePTP2FACode(trackerConfig config.TrackerConfig, login api.TrackerAuthLoginRequest) (string, error) { + if code := strings.TrimSpace(login.Code); code != "" { + return code, nil + } + return resolve2FACode(trackerConfig.OTPURI) +} + +func requestAntiCsrfToken(ctx context.Context, client *http.Client, baseURL string) (string, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+ptpUploadPath, nil) + if err != nil { + return "", fmt.Errorf("trackers: PTP build upload page request: %w", err) + } + httpReq.Header.Set("User-Agent", ptpUserAgent) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("trackers: PTP upload page: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("trackers: PTP read upload page: %w", err) + } + matches := ptpAntiCsrfPattern.FindStringSubmatch(string(body)) + if len(matches) < 2 { + return "", errors.New("trackers: PTP anti csrf token not found") + } + return strings.TrimSpace(matches[1]), nil +} + +func buildMultipartPayload(fields map[string]string, torrentPath string, fileField string) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + if key == "artist[]" { + for value := range strings.SplitSeq(fields[key], "\n") { + if strings.TrimSpace(value) == "" { + continue + } + if err := writer.WriteField(key, value); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) + } + } + continue + } + if key == "subtitles[]" || key == "trumpable[]" { + for value := range strings.SplitSeq(fields[key], ",") { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if err := writer.WriteField(key, trimmed); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) + } + } + continue + } + if err := writer.WriteField(key, fields[key]); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP write multipart field %q: %w", key, err) + } + } + + file, err := os.Open(torrentPath) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP open torrent file: %w", err) + } + defer file.Close() + part, err := writer.CreateFormFile(fileField, "placeholder.torrent") + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP create torrent form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("trackers: PTP copy torrent file: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("trackers: PTP close multipart writer: %w", err) + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func resolveTrackerTorrentPath(meta api.UploadSubject, dbPath string, tracker string) (string, error) { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "", errors.New("trackers: PTP tracker torrent path requires db path and source path") + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return filepath.Join(tmpDir, base+"."+strings.ToLower(strings.TrimSpace(tracker))+".torrent"), nil +} + +func resolveFailurePath(meta api.UploadSubject, dbPath string) (string, error) { + if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { + return "", errors.New("trackers: PTP failure path requires db path and source path") + } + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return filepath.Join(tmpDir, "[PTP]upload_failure.html"), nil +} + +func loadCookies(ctx context.Context, dbPath string) (map[string]string, error) { + values, err := cookiepkg.LoadTrackerCookieMap(ctx, dbPath, "PTP") + if err != nil { + return nil, fmt.Errorf("trackers: %w", err) + } + return values, nil +} + +func saveCookies(ctx context.Context, dbPath string, client *http.Client, baseURL string) error { + if client == nil || client.Jar == nil { + return errors.New("trackers: PTP login returned no cookie jar") + } + parsed, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("trackers: PTP parse cookie URL: %w", err) + } + cookies := make(map[string]string) + for _, cookie := range client.Jar.Cookies(parsed) { + if cookie == nil || strings.TrimSpace(cookie.Name) == "" { + continue + } + if strings.TrimSpace(cookie.Value) == "" { + continue + } + cookies[strings.TrimSpace(cookie.Name)] = cookie.Value + } + if len(cookies) == 0 { + return errors.New("trackers: PTP login returned no usable cookies") + } + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", cookies); err != nil { + return fmt.Errorf("trackers: PTP save cookies: %w", err) + } + return nil +} + +func passkeyFromAnnounce(announceURL string) (string, error) { + parsed, err := url.Parse(announceURL) + if err != nil { + return "", fmt.Errorf("trackers: PTP parse announce URL: %w", err) + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 2 { + return "", errors.New("trackers: PTP failed to extract passkey from announce_url") + } + return parts[0], nil +} + +func resolve2FACode(otpURI string) (string, error) { + trimmed := strings.TrimSpace(otpURI) + if trimmed == "" { + return "", errors.New("otp_uri not configured") + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("trackers: PTP parse otp_uri: %w", err) + } + secret := strings.TrimSpace(parsed.Query().Get("secret")) + if secret == "" { + return "", errors.New("otp_uri missing secret") + } + period := 30 + if value := strings.TrimSpace(parsed.Query().Get("period")); value != "" { + if parsedValue, parseErr := strconv.Atoi(value); parseErr == nil && parsedValue > 0 { + period = parsedValue + } + } + decoder := base32.StdEncoding.WithPadding(base32.NoPadding) + secretBytes, err := decoder.DecodeString(strings.ToUpper(secret)) + if err != nil { + return "", fmt.Errorf("trackers: PTP decode otp secret: %w", err) + } + counterTime := time.Now().Unix() / int64(period) + if counterTime < 0 { + return "", errors.New("totp counter before unix epoch") + } + counter := uint64(counterTime) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + mac := hmac.New(sha1.New, secretBytes) + _, _ = mac.Write(buf) + hash := mac.Sum(nil) + offset := hash[len(hash)-1] & 0x0f + code := (int(hash[offset])&0x7f)<<24 | int(hash[offset+1])<<16 | int(hash[offset+2])<<8 | int(hash[offset+3]) + return fmt.Sprintf("%06d", code%1000000), nil +} + +func resolveUploadName(meta api.UploadSubject) string { + if name := strings.TrimSpace(meta.ReleaseName); name != "" { + return name + } + if name := strings.TrimSpace(meta.ReleaseNameNoTag); name != "" { + return name + } + if name := strings.TrimSpace(meta.Filename); name != "" { + return name + } + return pathutil.Base(meta.SourcePath) +} + +func resolveType(meta api.UploadSubject) string { + category := strings.ToLower(strings.TrimSpace(string(meta.Identity.Category))) + if category == "" { + category = strings.ToLower(strings.TrimSpace(string(meta.Identity.Category))) + } + if meta.ProviderMetadata.IMDB != nil && strings.Contains(strings.ToLower(meta.ProviderMetadata.IMDB.Type), "concert") { + return "Music" + } + if meta.ProviderMetadata.TMDB != nil && + (strings.Contains(strings.ToLower(meta.ProviderMetadata.TMDB.Genres), "documentary") || strings.Contains(strings.ToLower(meta.ProviderMetadata.TMDB.Keywords), "documentary")) { + return "Documentary" + } + if category == "movie" { + return "Feature Film" + } + if category == "tv" { + if meta.TVPack { + return "Miniseries" + } + return "Short Film" + } + return "Feature Film" +} + +func resolveCodec(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "BD50" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + return "DVD9" + } + codec := strings.TrimSpace(meta.VideoCodec) + if codec == "" { + codec = strings.TrimSpace(meta.VideoEncode) + } + replacer := strings.NewReplacer("AVC", "H.264", "HEVC", "H.265") + codec = replacer.Replace(codec) + if meta.HasEncodeSettings { + codec = strings.ReplaceAll(codec, "H.", "x") + } + if codec == "" { + return "Other" + } + return codec +} + +func resolveResolution(meta api.UploadSubject) (string, string) { + resolution := strings.TrimSpace(meta.Release.Resolution) + if resolution == "" { + resolution = "Other" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + source := strings.TrimSpace(meta.Source) + source = strings.ReplaceAll(source, " DVD", "") + if source != "" { + return source, "" + } + } + if strings.EqualFold(resolution, "OTHER") { + return "Other", "Other" + } + return resolution, "" +} + +func resolveContainer(meta api.UploadSubject) string { + switch strings.ToUpper(strings.TrimSpace(meta.DiscType)) { + case "BDMV": + return "m2ts" + case "DVD": + return "VOB IFO" + default: + switch strings.ToLower(filepath.Ext(firstFile(meta))) { + case ".mkv": + return "MKV" + case ".mp4": + return "MP4" + default: + return "Other" + } + } +} + +func resolveSource(source string) string { + switch strings.TrimSpace(source) { + case "Blu-ray", "BluRay": + return "Blu-ray" + case "HD DVD", "HDDVD": + return "HD-DVD" + case "Web": + return "WEB" + case "HDTV", "UHDTV": + return "HDTV" + case "NTSC", "PAL": + return "DVD" + default: + return "OtherR" + } +} + +func resolveSubtitles(meta api.UploadSubject) []int { + if len(meta.SubtitleLanguages) == 0 { + return []int{44} + } + ids := make([]int, 0, len(meta.SubtitleLanguages)) + seen := make(map[int]struct{}) + for _, language := range meta.SubtitleLanguages { + if value, ok := subtitleIDs[strings.ToLower(strings.TrimSpace(language))]; ok { + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + ids = append(ids, value) + } + } + if len(ids) == 0 { + return []int{44} + } + return ids +} + +func resolveRemasterTitle(meta api.UploadSubject) string { + parts := make([]string, 0, 8) + distributor := strings.ToUpper(strings.TrimSpace(meta.Distributor)) + switch distributor { + case "WARNER ARCHIVE", "WARNER ARCHIVE COLLECTION", "WAC": + parts = append(parts, "Warner Archive Collection") + case "CRITERION", "CRITERION COLLECTION", "CC": + parts = append(parts, "The Criterion Collection") + case "MASTERS OF CINEMA", "MOC": + parts = append(parts, "Masters of Cinema") + } + edition := strings.TrimSpace(meta.Edition) + switch { + case strings.Contains(strings.ToLower(edition), "director's cut"): + parts = append(parts, "Director's Cut") + case strings.Contains(strings.ToLower(edition), "extended"): + parts = append(parts, "Extended Edition") + case strings.Contains(strings.ToLower(edition), "theatrical"): + parts = append(parts, "Theatrical Cut") + case strings.Contains(strings.ToLower(edition), "uncut"): + parts = append(parts, "Uncut") + case strings.Contains(strings.ToLower(edition), "unrated"): + parts = append(parts, "Unrated") + case edition != "": + parts = append(parts, edition) + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + parts = append(parts, "Remux") + } + audio := strings.TrimSpace(meta.Audio) + if strings.Contains(audio, "DTS:X") { + parts = append(parts, "DTS:X") + } + if strings.Contains(audio, "Atmos") { + parts = append(parts, "Dolby Atmos") + } + if strings.Contains(audio, "Dual") { + parts = append(parts, "Dual Audio") + } + if strings.Contains(audio, "Dubbed") { + parts = append(parts, "English Dub") + } + if meta.HDR == "" && meta.BitDepth == "10" { + parts = append(parts, "10-bit") + } + if strings.Contains(meta.HDR, "DV") { + parts = append(parts, "Dolby Vision") + } + if strings.Contains(meta.HDR, "HDR10+") { + parts = append(parts, "HDR10+") + } else if strings.Contains(meta.HDR, "HDR") { + parts = append(parts, "HDR10") + } + if strings.Contains(meta.HDR, "HLG") { + parts = append(parts, "HLG") + } + if meta.HasCommentary { + parts = append(parts, "With Commentary") + } + return strings.Join(parts, " / ") +} + +func resolveGroupTitleYear(meta api.UploadSubject) (string, string) { + title := "" + year := 0 + if meta.ProviderMetadata.TMDB != nil { + title = strings.TrimSpace(meta.ProviderMetadata.TMDB.Title) + year = meta.ProviderMetadata.TMDB.Year + } + if title == "" && meta.ProviderMetadata.IMDB != nil { + title = strings.TrimSpace(meta.ProviderMetadata.IMDB.Title) + year = meta.ProviderMetadata.IMDB.Year + } + if title == "" { + title = strings.TrimSpace(meta.Release.Title) + } + if year == 0 { + year = meta.Release.Year + } + if year == 0 { + return title, "" + } + return title, strconv.Itoa(year) +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + if value := strings.TrimSpace(meta.ProviderMetadata.TMDB.Poster); value != "" { + return value + } + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Cover) + } + return "" +} + +func resolveOverview(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + if value := strings.TrimSpace(meta.ProviderMetadata.TMDB.Overview); value != "" { + return value + } + } + if meta.ProviderMetadata.IMDB != nil { + if value := strings.TrimSpace(meta.ProviderMetadata.IMDB.Plot); value != "" { + return value + } + } + return "" +} + +func resolveTrailer(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.YouTube) + } + return "" +} + +func resolveDirectors(meta api.UploadSubject) []string { + if meta.ProviderMetadata.TMDB != nil && len(meta.ProviderMetadata.TMDB.Directors) > 0 { + return append([]string{}, meta.ProviderMetadata.TMDB.Directors...) + } + if meta.ProviderMetadata.IMDB != nil && len(meta.ProviderMetadata.IMDB.Directors) > 0 { + names := make([]string, 0, len(meta.ProviderMetadata.IMDB.Directors)) + for _, person := range meta.ProviderMetadata.IMDB.Directors { + if strings.TrimSpace(person.Name) != "" { + names = append(names, strings.TrimSpace(person.Name)) + } + } + return names + } + return nil +} + +func resolveTags(meta api.UploadSubject) string { + values := make([]string, 0, 8) + if meta.ProviderMetadata.TMDB != nil { + for item := range strings.SplitSeq(meta.ProviderMetadata.TMDB.Genres, ",") { + trimmed := strings.ToLower(strings.TrimSpace(item)) + if trimmed != "" { + values = append(values, trimmed) + } + } + } + if len(values) == 0 && strings.TrimSpace(meta.Release.Genre) != "" { + for item := range strings.SplitSeq(meta.Release.Genre, ",") { + trimmed := strings.ToLower(strings.TrimSpace(item)) + if trimmed != "" { + values = append(values, trimmed) + } + } + } + if len(values) == 0 { + values = append(values, "action") + } + seen := make(map[string]struct{}, len(values)) + filtered := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + filtered = append(filtered, value) + } + return strings.Join(filtered, ", ") +} + +func firstFile(meta api.UploadSubject) string { + if len(meta.FileList) > 0 { + return meta.FileList[0] + } + return meta.SourcePath +} + +func normalizedAnnounceURL(value string) string { + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(trimmed, "http://please.passthepopcorn.me") { + return strings.Replace(trimmed, "http://", "https://", 1) + } + return trimmed +} + +func extractAlertError(body string) string { + start := strings.Index(body, `alert alert--error`) + if start == -1 { + return "" + } + segment := body[start:] + end := strings.Index(segment, "
") + if end != -1 { + segment = segment[:end] + } + return stripTags(segment) +} + +func stripTags(value string) string { + inTag := false + var builder strings.Builder + for _, r := range value { + switch r { + case '<': + inTag = true + case '>': + inTag = false + default: + if !inTag { + builder.WriteRune(r) + } + } + } + return strings.Join(strings.Fields(builder.String()), " ") +} + +func compactError(value string) string { + trimmed := strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + if len(trimmed) > 220 { + return trimmed[:220] + } + return trimmed +} + +func stringFromAny(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.Itoa(int(typed)) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func joinInts(values []int) string { + if len(values) == 0 { + return "" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, strconv.Itoa(value)) + } + return strings.Join(parts, ",") +} + +func readBDSummary(meta api.UploadSubject, dbPath string) (string, error) { + tmpRoot, err := db.Subdir(dbPath, "tmp") + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return readTextFile(paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists))) +} + +func readTextFile(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", nil + } + payload, err := os.ReadFile(trimmed) + if err != nil { + return "", fmt.Errorf("trackers: PTP read text file: %w", err) + } + return string(payload), nil +} + +func buildQuestionnaire(meta api.UploadSubject, groupID string) *api.TrackerQuestionnaire { + if strings.TrimSpace(groupID) != "" { + return nil + } + title, year := resolveGroupTitleYear(meta) + fields := []api.TrackerQuestionnaireField{ + { + Key: "title", + Label: "Group Title", + Kind: "text", + Value: title, + Required: true, + }, + { + Key: "year", + Label: "Year", + Kind: "text", + Value: year, + Required: false, + Placeholder: "Release year", + }, + { + Key: "poster", + Label: "Poster URL", + Kind: "text", + Value: resolvePoster(meta), + Required: true, + }, + { + Key: "tags", + Label: "Tags", + Kind: "text", + Value: resolveTags(meta), + Required: true, + Placeholder: "Comma separated tags", + }, + { + Key: "trailer", + Label: "Trailer URL", + Kind: "text", + Value: resolveTrailer(meta), + Required: false, + Placeholder: "YouTube trailer URL", + }, + { + Key: "album_desc", + Label: "Group Description", + Kind: "textarea", + Value: resolveOverview(meta), + Required: false, + }, + } + return &api.TrackerQuestionnaire{ + Tracker: "PTP", + Fields: fields, + } +} + +var subtitleIDs = map[string]int{ + "arabic": 22, + "brazilian portuguese": 49, + "bulgarian": 29, + "chinese": 14, + "croatian": 23, + "czech": 30, + "danish": 10, + "dutch": 9, + "english": 3, + "english - forced": 50, + "english forced": 50, + "english intertitles": 51, + "estonian": 38, + "finnish": 15, + "french": 5, + "german": 6, + "greek": 26, + "hebrew": 40, + "hindi": 41, + "hungarian": 24, + "icelandic": 28, + "indonesian": 47, + "italian": 16, + "japanese": 8, + "korean": 19, + "latvian": 37, + "lithuanian": 39, + "norwegian": 12, + "polish": 17, + "portuguese": 21, + "romanian": 13, + "russian": 7, + "serbian": 31, + "slovak": 42, + "slovenian": 43, + "spanish": 4, + "swedish": 11, + "thai": 20, + "turkish": 18, + "ukrainian": 34, + "vietnamese": 25, +} diff --git a/internal/trackers/impl/standalone/ptp/upload_test.go b/internal/trackers/impl/standalone/ptp/upload_test.go new file mode 100644 index 00000000..dd98d661 --- /dev/null +++ b/internal/trackers/impl/standalone/ptp/upload_test.go @@ -0,0 +1,406 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptp + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + cookiepkg "github.com/autobrr/upbrr/internal/cookies" + servicedb "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestLoadCookiesSuccessReturnsNilError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "abc"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + + got, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if got["session"] != "abc" { + t.Fatalf("unexpected cookies: %#v", got) + } +} + +func TestResolveSessionForTrackerAuthPreservesCookiesOnTransientTokenFetch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "abc"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + baseURL := server.URL + server.Close() + + err := resolveSessionForTrackerAuthAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, baseURL) + if err == nil { + t.Fatal("expected transient token fetch error") + } + got, loadErr := loadCookies(ctx, dbPath) + if loadErr != nil { + t.Fatalf("loadCookies after transient error: %v", loadErr) + } + if got["session"] != "abc" { + t.Fatalf("expected transient failure to preserve cookies, got %#v", got) + } +} + +func TestLoginAndFetchAntiCsrfTokenReturnsPersistenceError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) + })) + t.Cleanup(server.Close) + + _, _, err := resolveSession(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, filepath.Join(t.TempDir(), "upbrr.db"), server.URL, api.NopLogger{}) + if err == nil { + t.Fatal("expected persistence error") + } + if !strings.Contains(err.Error(), "persist login cookies") { + t.Fatalf("expected persistence error, got %v", err) + } +} + +func TestLoginAndFetchAntiCsrfTokenDoesNotOverwriteCookiesWhenTokenMissing(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == ptpUploadPath { + _, _ = w.Write([]byte("logged out")) + return + } + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`{"Result":"Ok"}`)) + })) + t.Cleanup(server.Close) + + _, _, err := resolveSession(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, server.URL, api.NopLogger{}) + if err == nil { + t.Fatal("expected missing token error") + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "existing" { + t.Fatalf("missing token must not overwrite stored cookies, got %#v", values) + } +} + +func TestLoginAndFetchAntiCsrfTokenPersistsCookiesAfterTokenGate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) + })) + t.Cleanup(server.Close) + + _, token, err := resolveSession(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, server.URL, api.NopLogger{}) + if err != nil { + t.Fatalf("resolveSession: %v", err) + } + if token != "token" { + t.Fatalf("unexpected token %q", token) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "new" { + t.Fatalf("expected saved login cookies, got %#v", values) + } +} + +func TestLoginAndFetchAntiCsrfTokenRejectsEmptyJarWithoutReplacingCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == ptpUploadPath: + _, _ = w.Write([]byte("logged out")) + case r.URL.Path == "/ajax.php" && r.URL.RawQuery == "action=login": + _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + _, _, err := resolveSession(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, server.URL, api.NopLogger{}) + if err == nil || !strings.Contains(err.Error(), "no usable cookies") { + t.Fatalf("expected empty cookie jar error, got %v", err) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "existing" { + t.Fatalf("empty login jar must preserve stored cookies, got %#v", values) + } +} + +func TestResolveSessionForTrackerAuthLoginUsesManual2FACode(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + var gotCode string + loginRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + return + } + loginRequests++ + if loginRequests == 1 { + _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) + return + } + gotCode = r.FormValue("TfaCode") + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`{"Result":"Ok","AntiCsrfToken":"token"}`)) + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, api.TrackerAuthLoginRequest{Code: "654321"}, server.URL) + if err != nil { + t.Fatalf("ResolveSessionForTrackerAuthLogin: %v", err) + } + if gotCode != "654321" { + t.Fatalf("expected manual 2FA code, got %q", gotCode) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "new" { + t.Fatalf("expected saved 2FA login cookies, got %#v", values) + } +} + +func TestResolveSessionForTrackerAuthLoginMarksSubmitted2FARejected(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + loginRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + loginRequests++ + if loginRequests == 1 { + _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) + return + } + _, _ = w.Write([]byte(`{"Result":"Invalid"}`)) + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}, server.URL) + if !errors.Is(err, ErrSubmitted2FARejected) { + t.Fatalf("expected submitted 2FA rejection marker, got %v", err) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "existing" { + t.Fatalf("submitted 2FA rejection must preserve stored cookies, got %#v", values) + } +} + +func TestResolveSessionForTrackerAuthLoginPreCodeFailureIsNotSubmitted2FARejected(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ajax.php" || r.URL.RawQuery != "action=login" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"Result":"Invalid"}`)) + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, api.TrackerAuthLoginRequest{Code: "000000"}, server.URL) + if err == nil || !strings.Contains(err.Error(), "login failed") { + t.Fatalf("expected pre-code login failed error, got %v", err) + } + if errors.Is(err, ErrSubmitted2FARejected) { + t.Fatalf("pre-code login failure must not carry submitted 2FA marker: %v", err) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "existing" { + t.Fatalf("pre-code login failure must preserve stored cookies, got %#v", values) + } +} + +func TestResolveSessionForTrackerAuthLoginMissing2FACodePreservesCookies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbPath := newPTPAuthDB(t) + if err := cookiepkg.SaveTrackerCookieMap(ctx, dbPath, "PTP", map[string]string{"session": "existing"}); err != nil { + t.Fatalf("SaveTrackerCookieMap: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == ptpUploadPath: + _, _ = w.Write([]byte("logged out")) + case r.URL.Path == "/ajax.php" && r.URL.RawQuery == "action=login": + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "new", + Path: "/", + }) + _, _ = w.Write([]byte(`{"Result":"TfaRequired"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + err := resolveSessionForTrackerAuthLoginAt(ctx, config.TrackerConfig{ + Username: "user", + Password: "pass", + AnnounceURL: "https://please.passthepopcorn.me/passkey/announce", + }, dbPath, api.TrackerAuthLoginRequest{}, server.URL) + if err == nil || !strings.Contains(err.Error(), "2FA required") { + t.Fatalf("expected missing 2FA error, got %v", err) + } + values, err := loadCookies(ctx, dbPath) + if err != nil { + t.Fatalf("loadCookies: %v", err) + } + if values["session"] != "existing" { + t.Fatalf("missing 2FA code must preserve stored cookies, got %#v", values) + } +} + +func newPTPAuthDB(t *testing.T) string { + t.Helper() + + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "upbrr.db") + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + repo, err := servicedb.OpenWithLoggerContext(ctx, dbPath, api.NopLogger{}) + if err != nil { + t.Fatalf("OpenWithLoggerContext: %v", err) + } + if err := repo.MigrateContext(ctx); err != nil { + _ = repo.Close() + t.Fatalf("MigrateContext: %v", err) + } + _ = repo.Close() + return dbPath +} diff --git a/internal/trackers/impl/standalone/pts/bbcode.go b/internal/trackers/impl/standalone/pts/bbcode.go new file mode 100644 index 00000000..cef77917 --- /dev/null +++ b/internal/trackers/impl/standalone/pts/bbcode.go @@ -0,0 +1,83 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +var sceneNFOPattern = regexp.MustCompile(`(?is)\[center\]\[spoiler=.*? nfo:\]\[code\].*?\[/code\]\[/spoiler\]\[/center\]`) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = strings.NewReplacer( + "[user]", + "", + "[/user]", + "", + "[align=left]", + "", + "[/align]", + "", + "[right]", + "", + "[/right]", + "", + "[align=right]", + "", + "[/align=right]", + "", + "[sup]", + "", + "[/sup]", + "", + "[sub]", + "", + "[/sub]", + "", + "[alert]", + "", + "[/alert]", + "", + "[note]", + "", + "[/note]", + "", + "[hr]", + "", + "[/hr]", + "", + "[h1]", + "[u][b]", + "[/h1]", + "[/b][/u]", + "[h2]", + "[u][b]", + "[/h2]", + "[/b][/u]", + "[h3]", + "[u][b]", + "[/h3]", + "[/b][/u]", + "[ul]", + "", + "[/ul]", + "", + "[ol]", + "", + "[/ol]", + "", + "[hide]", + "", + "[/hide]", + "", + ).Replace(value) + value = sceneNFOPattern.ReplaceAllString(value, "") + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveSpoiler(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/pts/bbcode_test.go b/internal/trackers/impl/standalone/pts/bbcode_test.go new file mode 100644 index 00000000..e214ea1c --- /dev/null +++ b/internal/trackers/impl/standalone/pts/bbcode_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "strings" + "testing" +) + +func TestFinalizeDescription(t *testing.T) { + input := "[hide]x[/hide]\n[comparison=A, B]https://img.example/a.png https://img.example/b.png[/comparison]" + got := finalizeDescription(input) + if strings.Contains(got, "[hide]") { + t.Fatalf("expected hide tags removed for PTS, got %q", got) + } + if !strings.Contains(got, "[center]A | B") { + t.Fatalf("expected comparison centered for PTS, got %q", got) + } +} diff --git a/internal/trackers/impl/standalone/pts/definition.go b/internal/trackers/impl/standalone/pts/definition.go new file mode 100644 index 00000000..0367eb75 --- /dev/null +++ b/internal/trackers/impl/standalone/pts/definition.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, assets) + return trackers.DescriptionResult{Group: "pts", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/pts/dupe.go b/internal/trackers/impl/standalone/pts/dupe.go new file mode 100644 index 00000000..7e76a664 --- /dev/null +++ b/internal/trackers/impl/standalone/pts/dupe.go @@ -0,0 +1,110 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for PTS dupe search", nil) + } + baseURL := ptsBaseURL(s.cfg) + parsed, err := url.Parse(baseURL) + if err != nil { + return dupe.Failed(dupe.FailureInternal, "PTS search failed", err) + } + trackerCookies, err := cookies.LoadTrackerHTTPCookies(ctx, s.cfg.MainSettings.DBPath, "PTS", parsed.Hostname()) + if err != nil { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing valid PTS cookies", nil) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/torrents.php", nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "PTS search failed", err) + } + req.URL.RawQuery = url.Values{ + "incldead": {"1"}, + "search": {fmt.Sprintf("tt%07d", meta.Identity.IMDBID)}, + "search_area": {"4"}, + }.Encode() + req.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(req, trackerCookies) + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "PTS search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "PTS search failed", nil) + } + root, err := xhtml.Parse(resp.Body) + if err != nil { + return dupe.Failed(dupe.FailureResponseParse, "PTS search failed", err) + } + entries := make([]api.DupeEntry, 0) + ptsWalk(root, func(node *xhtml.Node) { + if node.Type != xhtml.ElementNode || node.Data != "b" { + return + } + if name := strings.TrimSpace(ptsNodeText(node)); name != "" { + entries = append(entries, api.DupeEntry{Name: name}) + } + }) + return dupe.Resolved(entries, nil) +} + +func ptsBaseURL(_ config.Config) string { + return "https://www.ptskit.org" +} + +func ptsWalk(node *xhtml.Node, visit func(*xhtml.Node)) { + if node == nil { + return + } + visit(node) + for child := node.FirstChild; child != nil; child = child.NextSibling { + ptsWalk(child, visit) + } +} + +func ptsNodeText(node *xhtml.Node) string { + if node == nil { + return "" + } + if node.Type == xhtml.TextNode { + return node.Data + } + var builder strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + builder.WriteString(ptsNodeText(child)) + } + return builder.String() +} diff --git a/internal/trackers/impl/standalone/pts/profile.go b/internal/trackers/impl/standalone/pts/profile.go new file mode 100644 index 00000000..398fde1e --- /dev/null +++ b/internal/trackers/impl/standalone/pts/profile.go @@ -0,0 +1,28 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" +) + +// Profile returns PTS identity, preparation, dupe, auth, and artifact behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "PTS", + BaseURL: baseURL, + DescriptionGroup: "pts", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + AuthCapability: standalone.CookieAuthCapability("PTS"), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"https://tracker.ptskit.com"}}, + } +} + +// New returns a fresh PTS definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/pts/upload.go b/internal/trackers/impl/standalone/pts/upload.go new file mode 100644 index 00000000..25617ab6 --- /dev/null +++ b/internal/trackers/impl/standalone/pts/upload.go @@ -0,0 +1,305 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pts + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://www.ptskit.org" + uploadURL = baseURL + "/takeupload.php" + sourceFlag = "[www.ptskit.org] PTSKIT" +) + +var idPattern = regexp.MustCompile(`download\.php\?id=([^&]+)`) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + questionnaire *api.TrackerQuestionnaire + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, cookies, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: PTS %s", state.blockedReason) + } + + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ + FieldName: "file", + FileName: "PTS.torrent", + Path: state.torrentPath, + }}) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "PTS") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, cookies, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + cookies []*http.Cookie, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTS request build: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + commonhttp.ApplyCookies(httpReq, cookies) + + resp, err := (&http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }).Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTS upload request: %w", err) + } + defer resp.Body.Close() + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther, + commonhttp.DefaultResponsePreviewBytes, + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: PTS read upload response: %w", err) + } + + location := strings.TrimSpace(resp.Header.Get("Location")) + torrentID := parseUploadID(location, string(responseBody)) + if (resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther) && torrentID != "" { + tURL := baseURL + "/details.php?id=" + url.QueryEscape(torrentID) + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "PTS", + TorrentID: torrentID, + TorrentURL: tURL, + DownloadURL: baseURL + "/download.php?id=" + url.QueryEscape(torrentID), + TorrentPath: artifactPath, + }}, + }, nil + } + + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "PTS", "upload_failure", responsePreview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("PTS", resp.StatusCode, responsePreview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "PTS", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "pts", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, []*http.Cookie, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, assets) + + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename), + fields: buildPayload(req.Meta, description), + questionnaire: buildQuestionnaire(req.Meta), + blockedReason: validateUpload(req.Meta), + } + cookies, err := loadCookies(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: PTS load cookies: %w", err) + } + return state, cookies, nil +} + +func buildPayload(meta api.UploadSubject, description string) map[string]string { + return map[string]string{ + "name": metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename), + "url": imdbURL(meta), + "descr": description, + "type": resolveType(meta), + } +} + +func buildDescription(meta api.UploadSubject, assets trackers.DescriptionAssets) string { + parts := make([]string, 0, 4) + if info := commonhttp.ReadOptionalFile(strings.TrimSpace(meta.MediaInfoTextPath)); strings.TrimSpace(info) != "" { + parts = append(parts, info) + } + if base := strings.TrimSpace(assets.Description); base != "" { + parts = append(parts, sanitizeDescription(base)) + } + if shots := screenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + parts = append(parts, "[right][url=https://github.com/autobrr/upbrr][size=1]upbrr[/size][/url][/right]") + return finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) +} + +func buildQuestionnaire(meta api.UploadSubject) *api.TrackerQuestionnaire { + if hasMandarin(meta) { + return nil + } + answer := strings.ToLower(strings.TrimSpace(standalone.QuestionnaireAnswers(meta, "PTS")["mandarin_override"])) + return &api.TrackerQuestionnaire{ + Tracker: "PTS", + Fields: []api.TrackerQuestionnaireField{{ + Key: "mandarin_override", + Label: "Mandarin Requirement", + Kind: "select", + Options: []string{"no", "yes"}, + Value: metautil.FirstNonEmptyTrimmed(answer, "no"), + Help: "PTS expects Mandarin audio or subtitles. Choose yes to override and upload anyway.", + Required: true, + }}, + } +} + +func validateUpload(meta api.UploadSubject) string { + if hasMandarin(meta) { + return "" + } + if strings.EqualFold(strings.TrimSpace(standalone.QuestionnaireAnswers(meta, "PTS")["mandarin_override"]), "yes") { + return "" + } + return "missing Mandarin audio/subtitles; answer the override questionnaire to continue" +} + +func hasMandarin(meta api.UploadSubject) bool { + for _, values := range [][]string{meta.AudioLanguages, meta.SubtitleLanguages} { + for _, value := range values { + lower := strings.ToLower(strings.TrimSpace(value)) + if strings.Contains(lower, "mandarin") || strings.Contains(lower, "chinese") { + return true + } + } + } + return false +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "PTS", "ptskit.org") + if err != nil { + return values, fmt.Errorf("trackers: PTS load cookies: %w", err) + } + return values, nil +} + +func resolveType(meta api.UploadSubject) string { + if _, err := meta.Identity.RequireCategory(); err != nil { + return "" + } + if meta.Anime { + return "407" + } + if isTV(meta) { + return "405" + } + return "404" +} + +func sanitizeDescription(input string) string { + return finalizeDescription(input) +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + lines := []string{"[center][b]Screenshots[/b]"} + for _, image := range images { + imgURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL)) + webURL := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.WebURL, imgURL)) + if imgURL == "" || webURL == "" { + continue + } + lines = append(lines, fmt.Sprintf("[url=%s][img]%s[/img][/url]", webURL, imgURL)) + } + lines = append(lines, "[/center]") + return strings.Join(lines, "\n") +} + +func parseUploadID(location string, body string) string { + for _, value := range []string{location, body} { + match := idPattern.FindStringSubmatch(value) + if len(match) == 2 { + return strings.TrimSpace(match[1]) + } + } + return "" +} + +func imdbURL(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) +} + +func isTV(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} diff --git a/internal/trackers/impl/standalone/questionnaire.go b/internal/trackers/impl/standalone/questionnaire.go new file mode 100644 index 00000000..af887209 --- /dev/null +++ b/internal/trackers/impl/standalone/questionnaire.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +// QuestionnaireAnswers returns the answers stored under a normalized tracker ID. +// Tracker answer maps use canonical uppercase IDs; missing or non-canonical keys return nil. +func QuestionnaireAnswers(meta api.UploadSubject, tracker string) map[string]string { + if len(meta.TrackerQuestionnaireAnswers) == 0 { + return nil + } + return meta.TrackerQuestionnaireAnswers[strings.ToUpper(strings.TrimSpace(tracker))] +} diff --git a/internal/trackers/impl/standalone/questionnaire_test.go b/internal/trackers/impl/standalone/questionnaire_test.go new file mode 100644 index 00000000..866b6d80 --- /dev/null +++ b/internal/trackers/impl/standalone/questionnaire_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package standalone + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestQuestionnaireAnswersNormalizesRequestedTrackerID(t *testing.T) { + want := map[string]string{"category": "movie"} + meta := api.UploadSubject{TrackerQuestionnaireAnswers: map[string]map[string]string{"PTP": want}} + + if got := QuestionnaireAnswers(meta, " ptp "); got["category"] != want["category"] { + t.Fatalf("expected canonical tracker answers, got %#v", got) + } +} + +func TestQuestionnaireAnswersRequiresCanonicalStoredTrackerID(t *testing.T) { + meta := api.UploadSubject{TrackerQuestionnaireAnswers: map[string]map[string]string{"ptp": {"category": "movie"}}} + if got := QuestionnaireAnswers(meta, "PTP"); got != nil { + t.Fatalf("expected non-canonical stored key to be ignored, got %#v", got) + } +} diff --git a/internal/trackers/impl/standalone/rtf/definition.go b/internal/trackers/impl/standalone/rtf/definition.go new file mode 100644 index 00000000..2043db60 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/definition.go @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + var ( + err error + assets trackers.DescriptionAssets + ) + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + } + description := buildDescription(assets) + return trackers.DescriptionResult{Group: "rtf", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/rtf/dupe.go b/internal/trackers/impl/standalone/rtf/dupe.go new file mode 100644 index 00000000..00a19e4b --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/dupe.go @@ -0,0 +1,481 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + rtfTorrentEndpoint = "https://retroflix.club/api/torrent" + rtfLoginEndpoint = "https://retroflix.club/api/login" + rtfBrowsePrefix = "https://retroflix.club/browse/t/" + rtfAgeGraceDays = 365*10 + 3 +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + logger api.Logger +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + logger: logger, + } +} + +func (h dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + cfg, ok := rtfTrackerConfig(h.cfg) + if !ok || (rtfAPIKey(cfg) == "" && !rtfHasCredentials(cfg)) { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + if h.http == nil { + return dupe.Failed(dupe.FailureInternal, "RTF handler misconfigured: no HTTP client", nil) + } + if !isRTFContentOldEnough(meta, time.Now().UTC()) { + return dupe.NotRun(dupe.NotRunUnsupportedContent, "RTF requires content older than 10 years", nil) + } + + params, ok := buildRTFSearchParams(meta) + if !ok { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb/title for RTF dupe search", nil) + } + + apiKey, err := h.ensureAPIKey(ctx, cfg) + if err != nil { + return dupe.Failed(dupe.FailureAuthentication, "RTF api key refresh failed", err) + } + + status, payload, err := h.search(ctx, params, apiKey) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "RTF request failed", err) + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + if !rtfHasCredentials(cfg) { + return dupe.NotRun(dupe.NotRunMissingCredentials, "RTF api key expired and username/password are missing", nil) + } + refreshedKey, refreshErr := h.refreshToken(ctx, cfg) + if refreshErr != nil { + return dupe.Failed(dupe.FailureAuthentication, "RTF api key refresh failed", refreshErr) + } + h.cacheRTFAPIKey(cfg, refreshedKey) + status, payload, err = h.search(ctx, params, refreshedKey) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "RTF request failed", err) + } + } + if status < 200 || status >= 300 { + return dupe.Failed(dupe.FailureResponseStatus, "RTF search failed", nil) + } + list, ok := anyToSlice(payload) + if !ok { + return dupe.Failed(dupe.FailureResponseParse, "RTF response parse failed", nil) + } + entries := make([]api.DupeEntry, 0, len(list)) + for _, raw := range list { + item, ok := raw.(map[string]any) + if !ok { + continue + } + id := stringFromAny(item["id"]) + entry := api.DupeEntry{ + Name: stringFromAny(item["name"]), + ID: id, + Link: buildRTFLink(item, id), + Download: buildRTFDownloadLink(id), + } + size := intFromAny(item["size"]) + if size > 0 { + entry.SizeKnown = true + entry.SizeBytes = size + } + if files, ok := item["files"].([]any); ok { + entry.FileCount = len(files) + for _, f := range files { + if fileMap, ok := f.(map[string]any); ok { + name := stringFromAny(fileMap["name"]) + if name != "" { + entry.Files = append(entry.Files, name) + } + } + } + } + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +func buildRTFSearchParams(meta api.DuplicateSubject) (url.Values, bool) { + params := url.Values{} + params.Set("includingDead", "1") + if meta.Identity.IMDBID != 0 { + params.Set("imdbId", fmt.Sprintf("tt%07d", meta.Identity.IMDBID)) + return params, true + } + query := cleanRTFSearchTitle(meta) + if query == "" { + return nil, false + } + params.Set("search", query) + return params, true +} + +func cleanRTFSearchTitle(meta api.DuplicateSubject) string { + query := strings.TrimSpace(meta.Release.Title) + if query == "" { + query = strings.TrimSpace(meta.ReleaseName) + } + if query == "" { + return "" + } + replacer := strings.NewReplacer( + ":", " ", + ",", "", + "'", "", + "’", "", + ) + query = replacer.Replace(query) + return strings.Join(strings.Fields(query), " ") +} + +func (h dupeSearcher) search(ctx context.Context, params url.Values, apiKey string) (int, any, error) { + headers := map[string]string{ + "accept": "application/json", + "Authorization": strings.TrimSpace(apiKey), + } + return rtfJSONRequest(ctx, h.http, http.MethodGet, rtfTorrentEndpoint, params, nil, headers) +} + +func (h dupeSearcher) ensureAPIKey(ctx context.Context, cfg config.TrackerConfig) (string, error) { + apiKey := rtfAPIKey(cfg) + if apiKey != "" { + return apiKey, nil + } + if !rtfHasCredentials(cfg) { + return "", errors.New("RTF api key unavailable: missing username/password for refresh") + } + token, err := h.refreshToken(ctx, cfg) + if err != nil { + return "", fmt.Errorf("RTF api key unavailable: %w", err) + } + h.cacheRTFAPIKey(cfg, token) + return token, nil +} + +func (h dupeSearcher) refreshToken(ctx context.Context, cfg config.TrackerConfig) (string, error) { + body := map[string]any{ + "username": strings.TrimSpace(cfg.Username), + "password": strings.TrimSpace(cfg.Password), + } + status, payload, err := rtfJSONRequest(ctx, h.http, http.MethodPost, rtfLoginEndpoint, nil, body, map[string]string{"accept": "application/json"}) + if err != nil { + return "", fmt.Errorf("RTF login request failed: %w", err) + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("RTF login returned status %d", status) + } + obj, ok := payload.(map[string]any) + if !ok { + return "", errors.New("RTF login returned invalid payload") + } + token := strings.TrimSpace(stringFromAny(obj["token"])) + if token == "" { + return "", errors.New("RTF login response missing token") + } + return token, nil +} + +// cacheRTFAPIKey updates the in-memory config map so later searches in this +// process can reuse a refreshed token. Writing back to durable config storage +// is handled elsewhere. +func (h dupeSearcher) cacheRTFAPIKey(cfg config.TrackerConfig, token string) { + if strings.TrimSpace(token) == "" || len(h.cfg.Trackers.Trackers) == 0 { + return + } + for key, current := range h.cfg.Trackers.Trackers { + if !strings.EqualFold(key, "RTF") { + continue + } + current.APIKey = token + current.PTPAPIKey = token + h.cfg.Trackers.Trackers[key] = current + return + } + cfg.APIKey = token + cfg.PTPAPIKey = token + h.cfg.Trackers.Trackers["RTF"] = cfg +} + +func rtfAPIKey(cfg config.TrackerConfig) string { + if key := strings.TrimSpace(cfg.APIKey); key != "" { + return key + } + return strings.TrimSpace(cfg.PTPAPIKey) +} + +func rtfHasCredentials(cfg config.TrackerConfig) bool { + return strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "" +} + +func buildRTFLink(item map[string]any, id string) string { + if link := strings.TrimSpace(stringFromAny(item["url"])); link != "" { + return link + } + if id == "" { + return "" + } + return rtfBrowsePrefix + url.PathEscape(id) +} + +func buildRTFDownloadLink(id string) string { + if strings.TrimSpace(id) == "" { + return "" + } + return rtfTorrentEndpoint + "/" + url.PathEscape(id) + "/download" +} + +func isRTFContentOldEnough(meta api.DuplicateSubject, now time.Time) bool { + cutoff := now.UTC().AddDate(0, 0, -rtfAgeGraceDays) + if date, ok := rtfReferenceDate(meta); ok { + return !date.After(cutoff) + } + year := rtfReferenceYear(meta) + if year == 0 { + return true + } + // Year-based fallback is intentionally looser than the date-based check + // because month/day precision is unavailable when only year is known. + return now.UTC().Year()-year > 9 +} + +func rtfReferenceDate(meta api.DuplicateSubject) (time.Time, bool) { + category := rtfCategory(meta) + switch category { + case "MOVIE": + return rtfMovieReleaseDate(meta) + case "TV": + return rtfMostRecentTVDate(meta) + default: + if date, ok := rtfMovieReleaseDate(meta); ok { + return date, true + } + return rtfMostRecentTVDate(meta) + } +} + +func rtfMovieReleaseDate(meta api.DuplicateSubject) (time.Time, bool) { + if meta.ProviderMetadata.TMDB == nil { + return time.Time{}, false + } + return parseRTFDate(meta.ProviderMetadata.TMDB.ReleaseDate) +} + +func rtfMostRecentTVDate(meta api.DuplicateSubject) (time.Time, bool) { + candidates := make([]time.Time, 0, 8) + if meta.ProviderMetadata.TMDB != nil { + if date, ok := parseRTFDate(meta.ProviderMetadata.TMDB.LastAirDate); ok { + candidates = append(candidates, date) + } + if date, ok := parseRTFDate(meta.ProviderMetadata.TMDB.FirstAirDate); ok { + candidates = append(candidates, date) + } + } + if meta.ProviderMetadata.TVmaze != nil { + if date, ok := parseRTFDate(meta.ProviderMetadata.TVmaze.Premiered); ok { + candidates = append(candidates, date) + } + } + if meta.ProviderMetadata.IMDB != nil { + for _, episode := range meta.ProviderMetadata.IMDB.Episodes { + if date, ok := rtfEpisodeDate(episode); ok { + candidates = append(candidates, date) + } + } + } + if len(candidates) == 0 { + return time.Time{}, false + } + latest := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.After(latest) { + latest = candidate + } + } + return latest, true +} + +func rtfEpisodeDate(episode api.IMDBEpisode) (time.Time, bool) { + if episode.ReleaseDate.Year > 0 { + month := episode.ReleaseDate.Month + if month <= 0 { + month = 1 + } + day := episode.ReleaseDate.Day + if day <= 0 { + day = 1 + } + return time.Date(episode.ReleaseDate.Year, time.Month(month), day, 0, 0, 0, 0, time.UTC), true + } + if episode.ReleaseYear > 0 { + return time.Date(episode.ReleaseYear, time.January, 1, 0, 0, 0, 0, time.UTC), true + } + return time.Time{}, false +} + +func parseRTFDate(raw string) (time.Time, bool) { + value := strings.TrimSpace(raw) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{"2006-01-02", time.RFC3339} { + parsed, err := time.Parse(layout, value) + if err != nil { + continue + } + return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC), true + } + return time.Time{}, false +} + +func rtfReferenceYear(meta api.DuplicateSubject) int { + if date, ok := rtfReferenceDate(meta); ok { + return date.Year() + } + if meta.Release.Year > 0 { + return meta.Release.Year + } + if meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Year > 0 { + return meta.ProviderMetadata.TMDB.Year + } + if meta.ProviderMetadata.IMDB != nil && meta.ProviderMetadata.IMDB.Year > 0 { + return meta.ProviderMetadata.IMDB.Year + } + if meta.ProviderMetadata.TVmaze != nil { + if date, ok := parseRTFDate(meta.ProviderMetadata.TVmaze.Premiered); ok { + return date.Year() + } + } + return 0 +} + +func rtfCategory(meta api.DuplicateSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + return strings.ToUpper(string(category)) +} + +func rtfJSONRequest( + ctx context.Context, + client *http.Client, + method, endpoint string, + params url.Values, + body map[string]any, + headers map[string]string, +) (int, any, error) { + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + return 0, nil, fmt.Errorf("rtf: encode request: %w", err) + } + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload)) + if err != nil { + return 0, nil, fmt.Errorf("rtf: create request: %w", err) + } + if len(params) > 0 { + req.URL.RawQuery = params.Encode() + } + for key, value := range headers { + req.Header.Set(key, value) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := client.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("rtf: request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, nil, nil + } + var result any + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + return resp.StatusCode, nil, fmt.Errorf("rtf: decode response: %w", err) + } + return resp.StatusCode, result, nil +} + +func rtfTrackerConfig(cfg config.Config) (config.TrackerConfig, bool) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "RTF") { + return entry, true + } + } + return config.TrackerConfig{}, false +} +func anyToSlice(value any) ([]any, bool) { + items, ok := value.([]any) + if ok { + return items, true + } + if object, ok := value.(map[string]any); ok { + for _, key := range []string{"data", "results", "torrents", "response"} { + if items, ok := object[key].([]any); ok { + return items, true + } + } + } + return nil, false +} +func stringFromAny(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return strings.TrimSpace(fmt.Sprint(value)) +} +func intFromAny(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/rtf/dupe_test.go b/internal/trackers/impl/standalone/rtf/dupe_test.go new file mode 100644 index 00000000..a939da45 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/dupe_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func adapterEvidence(result dupe.AdapterResult) ([]api.DupeEntry, []string, error) { + return result.Entries(), result.Notes(), result.Cause() +} + +type rtfRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f rtfRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestRTFHandlerUsesIMDBIDParamAndParsesResults(t *testing.T) { + t.Parallel() + + sawSearch := false + client := &http.Client{ + Transport: rtfRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/api/torrent": + sawSearch = true + query := req.URL.Query() + if got := query.Get("includingDead"); got != "1" { + t.Fatalf("unexpected includingDead value %q", got) + } + if got := query.Get("imdbId"); got != "tt0123456" { + t.Fatalf("unexpected imdbId value %q", got) + } + if got := query.Get("imdb"); got != "" { + t.Fatalf("unexpected imdb query value %q", got) + } + body := `[{"id":"42","name":"Movie.1990.1080p.BluRay-GRP","size":123456789,"files":[{"name":"Movie.1990.1080p.BluRay-GRP.mkv"}]}]` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + default: + t.Fatalf("unexpected request path %q", req.URL.Path) + return nil, nil + } + }), + } + + handler := dupe.NewAdapter(New(), "RTF", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "RTF": {APIKey: "good-key"}, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123456, Category: "MOVIE"}, + Release: api.ReleaseInfo{Year: 1990}, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + if !sawSearch { + t.Fatalf("expected /api/torrent call") + } + + entry := entries[0] + if entry.ID != "42" { + t.Fatalf("unexpected ID %q", entry.ID) + } + if entry.Link != "https://retroflix.club/browse/t/42" { + t.Fatalf("unexpected link %q", entry.Link) + } + if entry.Download != "https://retroflix.club/api/torrent/42/download" { + t.Fatalf("unexpected download %q", entry.Download) + } + if !entry.SizeKnown || entry.SizeBytes != 123456789 { + t.Fatalf("unexpected size known=%t size=%d", entry.SizeKnown, entry.SizeBytes) + } + if entry.FileCount != 1 || len(entry.Files) != 1 { + t.Fatalf("unexpected files payload count=%d files=%#v", entry.FileCount, entry.Files) + } +} + +func TestRTFHandlerRefreshesAndRetriesOn401(t *testing.T) { + t.Parallel() + + searchCalls := 0 + loginCalls := 0 + client := &http.Client{ + Transport: rtfRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/api/login": + loginCalls++ + raw, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read login body: %v", err) + } + body := string(raw) + if !strings.Contains(body, `"username":"user"`) || !strings.Contains(body, `"password":"pass"`) { + t.Fatal("unexpected login body fields") + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"token":"new-key"}`)), + Header: make(http.Header), + }, nil + case "/api/torrent": + searchCalls++ + auth := req.Header.Get("Authorization") + if searchCalls == 1 { + if auth != "old-key" { + t.Fatal("expected first search to use old key") + } + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(``)), + Header: make(http.Header), + }, nil + } + if auth != "new-key" { + t.Fatal("expected retry search to use new key") + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[]`)), + Header: make(http.Header), + }, nil + default: + t.Fatalf("unexpected request path %q", req.URL.Path) + return nil, nil + } + }), + } + + handler := dupe.NewAdapter(New(), "RTF", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "RTF": { + APIKey: "old-key", + Username: "user", + Password: "pass", + }, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{IMDBID: 123456, Category: "MOVIE"}, + Release: api.ReleaseInfo{Year: 1990}, + } + + entries, notes, err := adapterEvidence(handler.Search(context.Background(), meta)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("expected no notes, got %#v", notes) + } + if len(entries) != 0 { + t.Fatalf("expected no entries, got %d", len(entries)) + } + if searchCalls != 2 { + t.Fatalf("expected 2 search calls, got %d", searchCalls) + } + if loginCalls != 1 { + t.Fatalf("expected 1 login call, got %d", loginCalls) + } +} + +func TestRTFHandlerSkipsTooRecentContent(t *testing.T) { + t.Parallel() + + called := false + client := &http.Client{ + Transport: rtfRoundTripFunc(func(_ *http.Request) (*http.Response, error) { + called = true + t.Fatalf("no request should be sent for too-recent content") + return nil, nil + }), + } + + handler := dupe.NewAdapter(New(), "RTF", + config.Config{ + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{ + "RTF": {APIKey: "good-key"}, + }, + }, + }, client, api.NopLogger{}) + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Release: api.ReleaseInfo{Title: "Recent Movie"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Category: "MOVIE", + ReleaseDate: time.Now().UTC().AddDate(-1, 0, 0).Format("2006-01-02"), + }, + }, + } + + result := handler.Search(context.Background(), meta) + if result.Disposition() != dupe.DispositionNotRun || result.Code() != dupe.NotRunUnsupportedContent || + !strings.Contains(strings.ToLower(result.SafeMessage()), "10 years") { + t.Fatalf("unexpected result disposition=%v code=%q message=%q", result.Disposition(), result.Code(), result.SafeMessage()) + } + if called { + t.Fatalf("request should not have been made") + } +} diff --git a/internal/trackers/impl/standalone/rtf/profile.go b/internal/trackers/impl/standalone/rtf/profile.go new file mode 100644 index 00000000..0c5f6686 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/profile.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns RTF identity, preparation, dupe, auth, and policy behavior. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "RTF", + BaseURL: defaultBaseURL, + DescriptionGroup: "rtf", + UploadContentMode: trackers.UploadContentModeScreenshots, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + DupePolicy: &trackers.DupePolicy{ContainsFilenameMatch: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"peer.retroflix"}, + CommentURLPatterns: []string{defaultBaseURL}, + DetailIDPattern: "(?i)retroflix\\.club/browse/t/(\\d+)", + }, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "RTF", + DisplayName: "RTF", + AuthKind: "api_key_credential_refresh", + SupportsLogin: true, + SupportsAutoLogin: true, + RequiresAPIKey: true, + }, + AuthResolver: func(ctx context.Context, cfg config.TrackerConfig, dbPath string, request api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, request, defaultBaseURL) + }, + } +} + +// New returns a fresh RTF definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/rtf/rules.go b/internal/trackers/impl/standalone/rtf/rules.go new file mode 100644 index 00000000..46e4c835 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/rules.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +const minimumContentAgeReason = "content must be at least 10 years old" + +// rules strictly blocks content newer than RTF's ten-year eligibility cutoff. +func rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkRules} } + +func checkRules(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + if minimumContentAgeViolation(meta, time.Now().UTC()) { + return []api.RuleFailure{trackers.NewRuleFailure( + "minimum_content_age", + minimumContentAgeReason, + api.RuleDispositionStrict, + )}, nil + } + return nil, nil +} + +func minimumContentAgeViolation(meta api.RuleSubject, now time.Time) bool { + limit := now.UTC().AddDate(-10, 0, 3) + if releaseDate := ruleReleaseDate(meta); !releaseDate.IsZero() { + return releaseDate.After(limit) + } + return ruleReleaseYear(meta) > limit.Year() +} + +func ruleReleaseDate(meta api.RuleSubject) time.Time { + if meta.ProviderMetadata.TMDB == nil { + return time.Time{} + } + for _, value := range []string{ + strings.TrimSpace(meta.ProviderMetadata.TMDB.ReleaseDate), + strings.TrimSpace(meta.ProviderMetadata.TMDB.LastAirDate), + strings.TrimSpace(meta.ProviderMetadata.TMDB.FirstAirDate), + } { + if value == "" { + continue + } + if releaseDate, err := time.Parse("2006-01-02", value); err == nil { + return releaseDate + } + } + return time.Time{} +} + +func ruleReleaseYear(meta api.RuleSubject) int { + if meta.Release.Year > 0 { + return meta.Release.Year + } + if meta.ProviderMetadata.TMDB == nil { + return 0 + } + return meta.ProviderMetadata.TMDB.Year +} diff --git a/internal/trackers/impl/standalone/rtf/rules_test.go b/internal/trackers/impl/standalone/rtf/rules_test.go new file mode 100644 index 00000000..64bccf93 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/rules_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + "testing" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestMinimumContentAgeRuleIsStrict(t *testing.T) { + t.Parallel() + + ruleSet := Profile().Rules + if ruleSet == nil || ruleSet.Check == nil { + t.Fatal("RTF minimum content age rule is not registered") + } + failures, err := ruleSet.Check(context.Background(), api.RuleSubject{ + Release: api.ReleaseInfo{Year: 9999}, + }, nil) + if err != nil { + t.Fatalf("check RTF rules: %v", err) + } + if len(failures) != 1 { + t.Fatalf("RTF age failures = %#v, want one", failures) + } + failure := failures[0] + if failure.Rule != "minimum_content_age" || failure.Reason != minimumContentAgeReason || failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("RTF age failure = %#v, want strict minimum_content_age", failure) + } +} + +func TestMinimumContentAgeBoundary(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 18, 0, 0, 0, 0, time.UTC) + if minimumContentAgeViolation(api.RuleSubject{ + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{ReleaseDate: "2016-07-21"}}, + }, now) { + t.Fatal("content on the age boundary was rejected") + } + if !minimumContentAgeViolation(api.RuleSubject{ + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{ReleaseDate: "2016-07-22"}}, + }, now) { + t.Fatal("content newer than the age boundary was allowed") + } +} diff --git a/internal/trackers/impl/standalone/rtf/upload.go b/internal/trackers/impl/standalone/rtf/upload.go new file mode 100644 index 00000000..0e5cf06c --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/upload.go @@ -0,0 +1,496 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/metadata/metautil" + servicedb "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + defaultBaseURL = "https://retroflix.club" + sourceFlag = "sunshine" +) + +var newHTTPClient = func() *http.Client { return &http.Client{Timeout: 40 * time.Second} } + +type uploadResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + Torrent struct { + ID any `json:"id"` + } `json:"torrent"` +} + +type uploadState struct { + torrentPath string + releaseName string + description string + payload map[string]any + blockedReason string +} + +func uploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (api.UploadSummary, error) { + req.Intent = trackers.PreparationIntentUpload + plan, failure := trackers.PrepareAdapter(ctx, req, nil, func(ctx context.Context, input trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, input, baseURL) + }) + if failure != nil { + return api.UploadSummary{}, failure + } + summary, err := plan.Submit(ctx) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: RTF submit prepared upload: %w", err) + } + return summary, nil +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + return prepareUploadAt(ctx, req, defaultBaseURL) +} + +func prepareUploadAt(ctx context.Context, req trackers.PreparationInput, baseURL string) (trackers.PreparedOperation, error) { + uploadURL, err := joinURL(baseURL, "/api/upload") + if err != nil { + return trackers.PreparedOperation{}, err + } + + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview, err := buildUploadPreview(state, baseURL) + if err != nil { + return trackers.PreparedOperation{}, err + } + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: RTF %s", state.blockedReason) + } + + apiKey, err := resolveAPIKey(ctx, req, baseURL) + if err != nil { + return trackers.PreparedOperation{}, err + } + + body, err := json.Marshal(state.payload) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: RTF marshal upload payload: %w", err) + } + torrentURL, err := joinURL(baseURL, "/browse/t/") + if err != nil { + return trackers.PreparedOperation{}, err + } + downloadURL, err := joinURL(baseURL, "/api/torrent/") + if err != nil { + return trackers.PreparedOperation{}, err + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "RTF") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload( + submitCtx, req, state, uploadURL, body, apiKey, torrentURL, downloadURL, announceURL, artifactPath, + ) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + uploadURL string, + body []byte, + apiKey string, + torrentURL string, + downloadURL string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, strings.NewReader(string(body))) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: RTF create upload request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", apiKey) + + resp, err := newHTTPClient().Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: RTF upload request: %w", err) + } + defer resp.Body.Close() + responseBody, _ := io.ReadAll(resp.Body) + + var decoded uploadResponse + _ = json.Unmarshal(responseBody, &decoded) + if resp.StatusCode == http.StatusCreated && !decoded.Error { + id := strings.TrimSpace(fmt.Sprint(decoded.Torrent.ID)) + if id == "" { + return api.UploadSummary{}, errors.New("trackers: RTF upload succeeded but torrent id missing") + } + tURL := torrentURL + id + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, tURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "RTF", + TorrentID: id, + TorrentURL: tURL, + DownloadURL: downloadURL + id + "/download", + TorrentPath: artifactPath, + }}, + }, nil + } + + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "RTF", "upload_failure", responseBody, ".json") + return api.UploadSummary{}, fmt.Errorf( + "trackers: RTF %s", + metautil.FirstNonEmptyTrimmed( + commonhttp.ExtractHTTPErrorDetail(responseBody), + commonhttp.RedactErrorDetail(decoded.Message), + fmt.Sprintf("upload failed with status %d", resp.StatusCode), + ), + ) +} + +func buildUploadPreview(state uploadState, baseURL string) (api.TrackerDryRunEntry, error) { + payload := make(map[string]string, len(state.payload)) + for key, value := range state.payload { + payload[key] = strings.TrimSpace(fmt.Sprint(value)) + } + endpoint, err := joinURL(baseURL, "/api/upload") + if err != nil { + return api.TrackerDryRunEntry{}, err + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "RTF", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "rtf", + Description: state.description, + Endpoint: endpoint, + Payload: payload, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }), nil +} + +func prepareUploadState(_ context.Context, req trackers.PreparationInput) (uploadState, error) { + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" && + (strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "") { + return uploadState{}, errors.New("trackers: RTF missing api_key or username/password") + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + var assets trackers.DescriptionAssets + if req.Assets != nil { + assets = *req.Assets + } else { + assets, err = trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + } + description := buildDescription(assets) + torrentBytes, err := os.ReadFile(torrentPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: RTF read torrent file: %w", err) + } + releaseName := metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename) + payload := map[string]any{ + "name": releaseName, + "description": description, + "mediaInfo": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), + "nfo": "", + "url": imdbURL(req.Meta), + "descr": description, + "poster": resolvePoster(req.Meta), + "type": resolveType(req.Meta), + "screenshots": screenshots(assets.Screenshots), + "isAnonymous": req.TrackerConfig.Anon, + "file": base64.StdEncoding.EncodeToString(torrentBytes), + } + return uploadState{ + torrentPath: torrentPath, + releaseName: releaseName, + description: description, + payload: payload, + blockedReason: validateEligibility(req.Meta), + }, nil +} + +// resolveAPIKey validates configured RTF API auth and persists a refreshed token when credentials are used. +// Callers must complete no-upload eligibility gates before invoking it. +func resolveAPIKey(ctx context.Context, req trackers.PreparationInput, baseURL string) (string, error) { + apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) + if apiKey != "" { + valid, err := testAPIKey(ctx, baseURL, apiKey) + if err == nil && valid { + return apiKey, nil + } + if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { + if err != nil { + return "", fmt.Errorf("trackers: RTF API key validation failed and username/password not configured: %w", err) + } + return "", errors.New("trackers: RTF API key invalid and username/password not configured") + } + } + if strings.TrimSpace(req.TrackerConfig.Username) == "" || strings.TrimSpace(req.TrackerConfig.Password) == "" { + return "", errors.New("trackers: RTF missing api_key or username/password") + } + refreshed, err := refreshAPIKey(ctx, baseURL, req.TrackerConfig) + if err != nil { + return "", err + } + if err := persistRefreshedAPIKey(ctx, req.Runtime.DBPath, refreshed); err != nil && req.Logger != nil { + req.Logger.Warnf("trackers: RTF failed to persist refreshed API key: %v", err) + } + return refreshed, nil +} + +// ResolveSessionForTrackerAuthLogin validates RTF API auth or refreshes and +// persists the API key with configured credentials for tracker-auth checks. +func ResolveSessionForTrackerAuthLogin(ctx context.Context, cfg config.TrackerConfig, dbPath string, _ api.TrackerAuthLoginRequest) error { + return resolveSessionForTrackerAuthLoginAt(ctx, cfg, dbPath, api.TrackerAuthLoginRequest{}, defaultBaseURL) +} + +func resolveSessionForTrackerAuthLoginAt( + ctx context.Context, + cfg config.TrackerConfig, + dbPath string, + _ api.TrackerAuthLoginRequest, + baseURL string, +) error { + apiKey := strings.TrimSpace(cfg.APIKey) + if apiKey != "" { + valid, err := testAPIKey(ctx, baseURL, apiKey) + if err == nil && valid { + return nil + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + if err != nil { + return fmt.Errorf("trackers: RTF API key validation failed and username/password not configured: %w", err) + } + return errors.New("trackers: RTF API key invalid and username/password not configured") + } + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return errors.New("trackers: RTF missing api_key or username/password") + } + refreshed, err := refreshAPIKey(ctx, baseURL, cfg) + if err != nil { + return err + } + if err := persistRefreshedAPIKey(ctx, dbPath, refreshed); err != nil { + return err + } + return nil +} + +func testAPIKey(ctx context.Context, baseURL string, apiKey string) (bool, error) { + testURL, err := joinURL(baseURL, "/api/test") + if err != nil { + return false, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) + if err != nil { + return false, fmt.Errorf("trackers: RTF create API test request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) + resp, err := newHTTPClient().Do(httpReq) + if err != nil { + return false, fmt.Errorf("trackers: RTF API test request: %w", err) + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK, nil +} + +func refreshAPIKey(ctx context.Context, baseURL string, cfg config.TrackerConfig) (string, error) { + payload := map[string]string{ + "username": strings.TrimSpace(cfg.Username), + "password": strings.TrimSpace(cfg.Password), + } + body, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("trackers: RTF marshal API login payload: %w", err) + } + loginURL, err := joinURL(baseURL, "/api/login") + if err != nil { + return "", err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(string(body))) + if err != nil { + return "", fmt.Errorf("trackers: RTF create API login request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + resp, err := newHTTPClient().Do(httpReq) + if err != nil { + return "", fmt.Errorf("trackers: RTF API login request: %w", err) + } + defer resp.Body.Close() + responseBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("trackers: RTF API login failed status=%d", resp.StatusCode) + } + var decoded struct { + Token string `json:"token"` + } + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return "", fmt.Errorf("trackers: RTF decode API login response: %w", err) + } + token := strings.TrimSpace(decoded.Token) + if token == "" { + return "", errors.New("trackers: RTF API login response missing token") + } + return token, nil +} + +func persistRefreshedAPIKey(ctx context.Context, dbPath string, token string) error { + dbPath = strings.TrimSpace(dbPath) + if dbPath == "" { + return errors.New("trackers: RTF persist refreshed API key: db path not configured") + } + repo, err := servicedb.OpenContext(ctx, dbPath) + if err != nil { + return fmt.Errorf("trackers: RTF persist refreshed API key open db: %w", err) + } + defer repo.Close() + cfg, err := config.LoadFromDatabase(ctx, repo) + if err != nil { + return fmt.Errorf("trackers: RTF persist refreshed API key load config: %w", err) + } + if cfg.Trackers.Trackers == nil { + cfg.Trackers.Trackers = map[string]config.TrackerConfig{} + } + trackerKey := "RTF" + for key := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(key), "RTF") { + trackerKey = key + break + } + } + trackerCfg := cfg.Trackers.Trackers[trackerKey] + trackerCfg.APIKey = strings.TrimSpace(token) + cfg.Trackers.Trackers[trackerKey] = trackerCfg + if err := config.SaveToDatabase(ctx, cfg, repo); err != nil { + return fmt.Errorf("trackers: RTF persist refreshed API key: %w", err) + } + return nil +} + +// joinURL resolves path against a profile-owned base URL. +func joinURL(baseURL string, path string) (string, error) { + parsed, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("trackers: RTF invalid base URL %q", baseURL) + } + ref, err := url.Parse(strings.TrimLeft(path, "/")) + if err != nil { + return "", fmt.Errorf("trackers: RTF invalid URL path %q: %w", path, err) + } + return parsed.ResolveReference(ref).String(), nil +} + +func buildDescription(assets trackers.DescriptionAssets) string { + return strings.TrimSpace(assets.Description) +} + +func validateEligibility(meta api.UploadSubject) string { + genres := strings.ToLower(genresText(meta) + "," + keywordsText(meta)) + for _, value := range []string{"xxx", "erotic", "porn", "adult", "orgy"} { + if strings.Contains(genres, value) { + return "adult content is not allowed" + } + } + if minimumContentAgeViolation(api.NewRuleSubject(meta), time.Now().UTC()) { + return minimumContentAgeReason + } + return "" +} + +func resolveType(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + if category == api.CanonicalCategoryMovie { + return "401" + } + return "402" +} + +func screenshots(images []api.ScreenshotImage) []string { + out := make([]string, 0, len(images)) + for _, image := range images { + if raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)); raw != "" { + out = append(out, raw) + } + } + return out +} + +func imdbURL(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.Identity.IMDBID) +} + +func resolvePoster(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "" + } + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Poster) +} + +func genresText(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Genres, meta.Release.Genre) + } + return metautil.FirstNonEmptyTrimmed(meta.Release.Genre) +} + +func keywordsText(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "" + } + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) +} diff --git a/internal/trackers/impl/standalone/rtf/upload_test.go b/internal/trackers/impl/standalone/rtf/upload_test.go new file mode 100644 index 00000000..be37b481 --- /dev/null +++ b/internal/trackers/impl/standalone/rtf/upload_test.go @@ -0,0 +1,320 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rtf + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + servicedb "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildUploadDryRunUsesDescriptionPayloadAndLeavesNFOEmpty(t *testing.T) { + root := t.TempDir() + torrentPath := filepath.Join(root, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + input := trackers.PreparationInput{ + Intent: trackers.PreparationIntentDryRun, + Tracker: "RTF", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + DescriptionOverride: "Custom description", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + Assets: &trackers.DescriptionAssets{Description: "Custom description"}, + } + plan, failure := New().Prepare(context.Background(), input) + if failure != nil { + t.Fatalf("unexpected error: %v", failure) + } + entry := plan.DryRun() + + if entry.Payload["description"] != "Custom description" { + t.Fatalf("expected description payload, got %#v", entry.Payload) + } + if entry.Payload["descr"] != "Custom description" { + t.Fatalf("expected descr payload mirror, got %#v", entry.Payload) + } + if entry.Payload["nfo"] != "" { + t.Fatalf("expected empty nfo payload, got %#v", entry.Payload["nfo"]) + } +} + +func TestUploadRefreshesExpiredAPIKeyAndPersistsIt(t *testing.T) { + root := t.TempDir() + torrentPath := filepath.Join(root, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + dbPath := filepath.Join(root, "upbrr.db") + seedRTFConfig(t, dbPath, config.TrackerConfig{ + APIKey: "old-token", + Username: "user", + Password: "pass", + }) + + var testedToken string + var loginCalled bool + var uploadToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/test": + testedToken = r.Header.Get("Authorization") + w.WriteHeader(http.StatusUnauthorized) + case "/api/login": + loginCalled = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"new-token"}`)) + case "/api/upload": + uploadToken = r.Header.Get("Authorization") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + summary, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "RTF", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + ReleaseName: "Release", + }, + TrackerConfig: config.TrackerConfig{ + APIKey: "old-token", + Username: "user", + Password: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + }, server.URL) + if err != nil { + t.Fatalf("upload: %v", err) + } + if summary.Uploaded != 1 { + t.Fatalf("expected upload success, got %#v", summary) + } + if testedToken != "old-token" { + t.Fatal("expected old token to be tested") + } + if !loginCalled { + t.Fatal("expected expired token to trigger API login") + } + if uploadToken != "new-token" { + t.Fatal("expected upload to use refreshed token") + } + if got := loadStoredRTFAPIKey(t, dbPath); got != "new-token" { + t.Fatal("expected refreshed token persisted") + } +} + +func TestUploadBlockedExpiredAPIKeyDoesNotRefreshOrPersist(t *testing.T) { + root := t.TempDir() + torrentPath := filepath.Join(root, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + dbPath := filepath.Join(root, "upbrr.db") + seedRTFConfig(t, dbPath, config.TrackerConfig{ + APIKey: "old-token", + Username: "user", + Password: "pass", + }) + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + _, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "RTF", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + ReleaseName: "Recent Release", + Release: api.ReleaseInfo{Year: 9999}, + }, + TrackerConfig: config.TrackerConfig{ + APIKey: "old-token", + Username: "user", + Password: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + }, server.URL) + if err == nil { + t.Fatal("expected blocked upload error") + } + if !strings.Contains(err.Error(), "content must be at least 10 years old") { + t.Fatalf("expected eligibility error, got %v", err) + } + if requests != 0 { + t.Fatalf("blocked upload must not call RTF auth or upload endpoints, got %d requests", requests) + } + if got := loadStoredRTFAPIKey(t, dbPath); got != "old-token" { + t.Fatal("blocked upload must leave stored token unchanged") + } +} + +func TestUploadUsesValidAPIKeyWithoutRefresh(t *testing.T) { + root := t.TempDir() + torrentPath := filepath.Join(root, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + var loginCalled bool + var uploadToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/test": + w.WriteHeader(http.StatusOK) + case "/api/login": + loginCalled = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"new-token"}`)) + case "/api/upload": + uploadToken = r.Header.Get("Authorization") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + if _, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "RTF", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + ReleaseName: "Release", + }, + TrackerConfig: config.TrackerConfig{ + APIKey: "valid-token", + }, + }, server.URL); err != nil { + t.Fatalf("upload: %v", err) + } + if loginCalled { + t.Fatal("valid token should not trigger API login") + } + if uploadToken != "valid-token" { + t.Fatal("expected upload to use original token") + } +} + +func TestUploadGeneratesMissingAPIKeyFromCredentials(t *testing.T) { + root := t.TempDir() + torrentPath := filepath.Join(root, "test.torrent") + if err := os.WriteFile(torrentPath, []byte("torrent-bytes"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + dbPath := filepath.Join(root, "upbrr.db") + seedRTFConfig(t, dbPath, config.TrackerConfig{Username: "user", Password: "pass"}) + + var testCalled bool + var uploadToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/test": + testCalled = true + w.WriteHeader(http.StatusOK) + case "/api/login": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"generated-token"}`)) + case "/api/upload": + uploadToken = r.Header.Get("Authorization") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"error":false,"torrent":{"id":123}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + if _, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "RTF", + Meta: api.UploadSubject{ + TorrentPath: torrentPath, + ReleaseName: "Release", + }, + TrackerConfig: config.TrackerConfig{ + Username: "user", + Password: "pass", + }, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}), + }, server.URL); err != nil { + t.Fatalf("upload: %v", err) + } + if testCalled { + t.Fatal("missing token should refresh directly without API test") + } + if uploadToken != "generated-token" { + t.Fatal("expected upload to use generated token") + } + if got := loadStoredRTFAPIKey(t, dbPath); got != "generated-token" { + t.Fatal("expected generated token persisted") + } +} + +func TestUploadRejectsMalformedBaseURL(t *testing.T) { + _, err := uploadAt(context.Background(), trackers.PreparationInput{ + Tracker: "RTF", + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }, "not a url") + if err == nil { + t.Fatal("expected malformed base URL error") + } + if !strings.Contains(err.Error(), "invalid base URL") { + t.Fatalf("expected invalid base URL error, got %v", err) + } +} + +func seedRTFConfig(t *testing.T, dbPath string, trackerCfg config.TrackerConfig) { + t.Helper() + + repo, err := servicedb.OpenContext(context.Background(), dbPath) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer repo.Close() + if err := repo.MigrateContext(context.Background()); err != nil { + t.Fatalf("MigrateContext: %v", err) + } + cfg := config.Config{ + MainSettings: config.MainSettingsConfig{DBPath: dbPath}, + Trackers: config.TrackersConfig{ + Trackers: map[string]config.TrackerConfig{"RTF": trackerCfg}, + }, + } + if err := config.SaveToDatabase(context.Background(), &cfg, repo); err != nil { + t.Fatalf("SaveToDatabase: %v", err) + } +} + +func loadStoredRTFAPIKey(t *testing.T, dbPath string) string { + t.Helper() + + repo, err := servicedb.OpenContext(context.Background(), dbPath) + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer repo.Close() + cfg, err := config.LoadFromDatabase(context.Background(), repo) + if err != nil { + t.Fatalf("LoadFromDatabase: %v", err) + } + return cfg.Trackers.Trackers["RTF"].APIKey +} diff --git a/internal/trackers/impl/standalone/spd/bbcode.go b/internal/trackers/impl/standalone/spd/bbcode.go new file mode 100644 index 00000000..534a4c58 --- /dev/null +++ b/internal/trackers/impl/standalone/spd/bbcode.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package spd + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.RemoveImageResize(value) + value = bbcode.ConvertNamedSpoilerToNormalSpoiler(value) + value = strings.NewReplacer("[note]", "Note: ", "[/note]", "", "[code]", "", "[/code]", "", "[*]", "• ").Replace(value) + value = bbcode.RemoveSpoiler(value) + value = bbcode.RemoveList(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/spd/dupe.go b/internal/trackers/impl/standalone/spd/dupe.go new file mode 100644 index 00000000..0f0415a8 --- /dev/null +++ b/internal/trackers/impl/standalone/spd/dupe.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package spd + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone/internal/jsondupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client + endpoint string +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + cfg: cfg, + http: httpClient, + endpoint: "https://speedapp.io/api/torrent", + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + apiKey := spdAPIKey(s.cfg) + if apiKey == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + params := url.Values{} + switch { + case meta.Identity.IMDBID != 0: + params.Set("imdbId", strconv.Itoa(meta.Identity.IMDBID)) + case strings.TrimSpace(meta.Release.Title) != "": + params.Set("search", strings.TrimSpace(meta.Release.Title)) + default: + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing imdb/title for SPD dupe search", nil) + } + return jsondupe.Search(ctx, s.http, jsondupe.ListSpec{ + Endpoint: s.endpoint, + Query: params, + Headers: http.Header{"Authorization": {apiKey}, "Accept": {"application/json"}}, + IDField: "id", + NameField: "name", + SizeField: "size", + Link: func(id string) string { return "https://speedapp.io/browse/" + id + "/" }, + FailureMessage: "SPD search failed", + }) +} + +func spdAPIKey(cfg config.Config) string { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "SPD") { + return strings.TrimSpace(entry.APIKey) + } + } + return "" +} diff --git a/internal/trackers/impl/standalone/spd/dupe_test.go b/internal/trackers/impl/standalone/spd/dupe_test.go new file mode 100644 index 00000000..e26d974e --- /dev/null +++ b/internal/trackers/impl/standalone/spd/dupe_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package spd + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDuplicateSearchUsesSPDQueryHeadersAndProjection(t *testing.T) { + requestErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("imdbId") != "1234567" || r.Header.Get("Authorization") != "secret" || r.Header.Get("Accept") != "application/json" { + requestErr <- errors.New("unexpected SPD duplicate request shape") + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = w.Write([]byte(`[{"id":"84","name":"Example.Release.2026.1080p-GRP","size":4321}]`)) + })) + defer server.Close() + + searcher := &dupeSearcher{ + cfg: config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "SPD": {APIKey: "secret"}, + }}}, + http: server.Client(), + endpoint: server.URL, + } + result := searcher.Search(context.Background(), api.DuplicateSubject{Identity: api.ExternalIdentity{IMDBID: 1234567}}) + select { + case err := <-requestErr: + t.Fatal(err) + default: + } + if result.Disposition() != dupe.DispositionResolved { + t.Fatalf("unexpected disposition=%v code=%q cause=%v", result.Disposition(), result.Code(), result.Cause()) + } + entries := result.Entries() + if len(entries) != 1 || entries[0].ID != "84" || entries[0].Link != "https://speedapp.io/browse/84/" || entries[0].SizeBytes != 4321 { + t.Fatalf("unexpected entries: %#v", entries) + } +} diff --git a/internal/trackers/impl/standalone/spd/profile.go b/internal/trackers/impl/standalone/spd/profile.go new file mode 100644 index 00000000..78a4dda5 --- /dev/null +++ b/internal/trackers/impl/standalone/spd/profile.go @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package spd + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns SPD identity, preparation, dupe, and policy behavior, +// including the strict requirement for matching TMDB or IMDb metadata. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "SPD", + BaseURL: baseURL, + DescriptionGroup: "spd", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + BannedGroupPolicy: &trackers.BannedGroupPolicy{ + DefaultEndpoint: baseURL + "/api/torrent/release-group/blacklist", + EndpointPath: "/api/torrent/release-group/blacklist", + RequireAPIKey: true, + RawAPIKeyFallback: true, + }, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + AudioPolicy: &trackers.AudioPolicy{ + AllowedLanguages: []string{"romanian"}, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"ramjet.speedapp.io", "ramjet.speedapp.to", "ramjet.speedappio.org"}, + }, + } +} + +// New returns a fresh SPD definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "spd", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/spd/upload.go b/internal/trackers/impl/standalone/spd/upload.go new file mode 100644 index 00000000..db1a52e5 --- /dev/null +++ b/internal/trackers/impl/standalone/spd/upload.go @@ -0,0 +1,472 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package spd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://speedapp.io" + uploadURL = baseURL + "/api/upload" +) + +type uploadState struct { + torrentPath string + description string + releaseName string + payload map[string]any + questionnaire *api.TrackerQuestionnaire + blockedReason string +} + +type uploadResponse struct { + Status bool `json:"status"` + Error bool `json:"error"` + DownloadURL string `json:"downloadUrl"` + Torrent struct { + ID any `json:"id"` + } `json:"torrent"` +} + +type channelResult struct { + ID any `json:"id"` + Tag string `json:"tag"` +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: SPD %s", state.blockedReason) + } + + body, err := json.Marshal(state.payload) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: SPD marshal upload payload: %w", err) + } + artifactPath, _ := trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "SPD") + apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, body, apiKey, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + body []byte, + apiKey string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, strings.NewReader(string(body))) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: SPD build upload request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", apiKey) + + result, err := commonhttp.ExecuteUpload(&http.Client{Timeout: 30 * time.Second}, httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "SPD", + SuccessStatus: func(status int) bool { return status == http.StatusOK }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: SPD execute upload: %w", err) + } + + var decoded uploadResponse + if err := json.Unmarshal(result.Body, &decoded); err != nil { + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "SPD", "upload_failure", result.Preview, ".txt") + if !result.Success { + return api.UploadSummary{}, commonhttp.UploadHTTPError("SPD", result.StatusCode, result.Preview) + } + return api.UploadSummary{}, fmt.Errorf("trackers: SPD decode response: %s", commonhttp.RedactErrorDetail(err.Error())) + } + if result.Success && decoded.Status && !decoded.Error { + torrentID := strings.TrimSpace(fmt.Sprint(decoded.Torrent.ID)) + if torrentID != "" && artifactPath != "" { + if dlErr := downloadTrackerTorrent(ctx, baseURL+"/api/torrent/"+torrentID+"/download", apiKey, artifactPath); dlErr != nil { + artifactPath = "" + } + } + return api.UploadSummary{ + Uploaded: 1, + UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "SPD", + TorrentID: torrentID, + TorrentURL: baseURL + "/browse/" + torrentID, + DownloadURL: baseURL + "/api/torrent/" + torrentID + "/download", + TorrentPath: artifactPath, + }}, + }, nil + } + + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "SPD", "upload_failure", result.Preview, ".json") + return api.UploadSummary{}, commonhttp.UploadHTTPError("SPD", result.StatusCode, result.Preview) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + payload := make(map[string]string, len(state.payload)) + for key, value := range state.payload { + payload[key] = strings.TrimSpace(fmt.Sprint(value)) + } + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "SPD", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "spd", + Description: state.description, + Endpoint: uploadURL, + Payload: payload, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "file", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, error) { + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: SPD missing api_key") + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + channelID, blockedReason, questionnaire := resolveChannel(ctx, req) + torrentBytes, err := os.ReadFile(torrentPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: SPD read torrent file: %w", err) + } + releaseName := normalizeName(metautil.FirstNonEmptyTrimmed(req.Meta.ReleaseName, req.Meta.Release.Title, req.Meta.Filename)) + payload := map[string]any{ + "bdInfo": trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, req.Meta), + "coverPhotoUrl": metautil.FirstNonEmptyTrimmed(req.Meta.ProviderMetadata.TMDB.Backdrop, req.Meta.ProviderMetadata.TMDB.Poster), + "description": genresText(req.Meta), + "media_info": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), + "name": releaseName, + "nfo": "", + "plot": metautil.FirstNonEmptyTrimmed(req.Meta.EpisodeOverview, req.Meta.ProviderMetadata.TMDB.Overview), + "poster": metautil.FirstNonEmptyTrimmed(req.Meta.ProviderMetadata.TMDB.Poster), + "technicalDetails": description, + "screenshots": combineUniqueScreenshots(assets.MenuImages, assets.Screenshots), + "type": resolveCategory(req.Meta), + "url": imdbURL(req.Meta), + "channel": channelID, + "file": base64.StdEncoding.EncodeToString(torrentBytes), + } + return uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: releaseName, + payload: payload, + questionnaire: questionnaire, + blockedReason: blockedReason, + }, nil +} + +func resolveChannel(ctx context.Context, req trackers.PreparationInput) (string, string, *api.TrackerQuestionnaire) { + answers := standalone.QuestionnaireAnswers(req.Meta, "SPD") + input := metautil.FirstNonEmptyTrimmed(strings.TrimSpace(answers["channel"]), strings.TrimSpace(req.TrackerConfig.Channel)) + if input == "" { + return "1", "", nil + } + if digitsOnly(input) { + return input, "", nil + } + id, err := lookupChannelID(ctx, req.TrackerConfig.APIKey, input) + if err == nil && id != "" { + return id, "", nil + } + return "", "answer the channel questionnaire with a valid channel id or tag", &api.TrackerQuestionnaire{ + Tracker: "SPD", + Fields: []api.TrackerQuestionnaireField{{ + Key: "channel", + Label: "Channel", + Kind: "text", + Value: input, + Placeholder: "1 or channel tag", + Required: true, + }}, + } +} + +func lookupChannelID(ctx context.Context, apiKey string, input string) (string, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/channel?search="+url.QueryEscape(input), nil) + if err != nil { + return "", fmt.Errorf("trackers: SPD build channel lookup request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(httpReq) + if err != nil { + return "", fmt.Errorf("trackers: SPD channel lookup request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var decoded []channelResult + if err := json.Unmarshal(body, &decoded); err != nil { + return "", fmt.Errorf("trackers: SPD unmarshal channel lookup response: %w", err) + } + for _, item := range decoded { + if strings.EqualFold(strings.TrimSpace(item.Tag), strings.TrimSpace(input)) { + return strings.TrimSpace(fmt.Sprint(item.ID)), nil + } + } + return "", errors.New("channel not found") +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + meta := req.Meta + var parts []string + + // Avoid unnecessary descriptions + if strings.TrimSpace(assets.Description) != "" || strings.TrimSpace(meta.EpisodeOverview) != "" { + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if logo, logoSize := descriptionunit3d.ResolveLogo(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig()); logo != "" { + parts = append(parts, fmt.Sprintf("[center][img=%d]https://image.tmdb.org/t/p/w300/%s[/img][/center]", logoSize, logo)) + } + + // TV Details + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") + } + + // User Description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // custom user signature + if signature := strings.TrimSpace(req.Runtime.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + // Signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("[url=%s]%s[/url]", link, text)) + + // Join and finalize + description := strings.Join(parts, "\n\n") + finalized := finalizeDescription(description) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "SPD", req.Runtime.DBPath, finalized, req.Logger) + } + + return finalized +} + +func resolveCategory(meta api.UploadSubject) string { + if _, err := meta.Identity.RequireCategory(); err != nil { + return "" + } + romanian := hasRomanian(meta) + if containsWord(genresText(meta), "documentary") || containsWord(keywordsText(meta), "documentary") { + if romanian { + return "63" + } + return "9" + } + if meta.Anime { + return "3" + } + if isTV(meta) { + if meta.TVPack { + if romanian { + return "66" + } + return "41" + } + if isSD(meta.Release.Resolution) { + if romanian { + return "46" + } + return "45" + } + if romanian { + return "44" + } + return "43" + } + if meta.Release.Resolution == "2160p" && !strings.EqualFold(meta.Type, "DISC") { + if romanian { + return "57" + } + return "61" + } + if strings.EqualFold(meta.Type, "DISC") { + if romanian { + return "24" + } + return "17" + } + if romanian { + return "29" + } + return "8" +} + +func hasRomanian(meta api.UploadSubject) bool { + for _, value := range append([]string{}, append(meta.AudioLanguages, meta.SubtitleLanguages...)...) { + if strings.EqualFold(strings.TrimSpace(value), "romanian") { + return true + } + } + for _, code := range meta.ProviderMetadata.TMDB.OriginCountry { + if strings.EqualFold(strings.TrimSpace(code), "RO") { + return true + } + } + return false +} + +func combineUniqueScreenshots(menu []api.ScreenshotImage, normal []api.ScreenshotImage) []string { + var out []string + seen := make(map[string]struct{}) + + for _, image := range menu { + u := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) + if u != "" && !isSeen(seen, u) { + out = append(out, u) + seen[u] = struct{}{} + } + } + for _, image := range normal { + u := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) + if u != "" && !isSeen(seen, u) { + out = append(out, u) + seen[u] = struct{}{} + } + } + return out +} + +func isSeen(seen map[string]struct{}, url string) bool { + _, ok := seen[url] + return ok +} + +func normalizeName(input string) string { + mapper := func(r rune) rune { + if r > unicode.MaxASCII { + return -1 + } + if strings.ContainsRune(`\/*?"<>|`, r) { + return -1 + } + return r + } + return strings.Join(strings.Fields(strings.Map(mapper, strings.ReplaceAll(input, ":", " -"))), " ") +} + +func imdbURL(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) +} + +func downloadTrackerTorrent(ctx context.Context, urlValue string, apiKey string, output string) error { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, urlValue, nil) + if err != nil { + return fmt.Errorf("trackers: SPD build torrent download request: %w", err) + } + httpReq.Header.Set("Authorization", strings.TrimSpace(apiKey)) + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(httpReq) + if err != nil { + return fmt.Errorf("trackers: SPD torrent download request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("trackers: SPD read torrent response: %w", err) + } + if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil { + return fmt.Errorf("trackers: SPD create torrent output dir: %w", err) + } + if err := os.WriteFile(output, body, 0o600); err != nil { + return fmt.Errorf("trackers: SPD write torrent output: %w", err) + } + return nil +} + +func digitsOnly(value string) bool { + _, err := strconv.Atoi(strings.TrimSpace(value)) + return err == nil +} + +func containsWord(a string, b string) bool { + return strings.Contains(strings.ToLower(a), strings.ToLower(b)) +} + +func isSD(res string) bool { + return strings.HasPrefix(strings.TrimSpace(res), "480") || strings.HasPrefix(strings.TrimSpace(res), "576") || + strings.HasPrefix(strings.TrimSpace(res), "540") +} + +func genresText(meta api.UploadSubject) string { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Genres, meta.Release.Genre) +} + +func keywordsText(meta api.UploadSubject) string { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) +} + +func isTV(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} diff --git a/internal/trackers/impl/standalone/thr/bbcode.go b/internal/trackers/impl/standalone/thr/bbcode.go new file mode 100644 index 00000000..4f764fa3 --- /dev/null +++ b/internal/trackers/impl/standalone/thr/bbcode.go @@ -0,0 +1,22 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +var sceneNFOPattern = regexp.MustCompile(`(?is)(\[hide=(?:Scene|FraMeSToR) NFO:\]\[pre\])(.*?)(\[/pre\]\[/hide\])`) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.ConvertNamedSpoilerToNamedHide(value) + value = bbcode.ConvertSpoilerToHide(value) + value = bbcode.ConvertCodeToPre(value) + value = sceneNFOPattern.ReplaceAllString(value, `$1[align=left]$2[/align]$3`) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/thr/definition.go b/internal/trackers/impl/standalone/thr/definition.go new file mode 100644 index 00000000..baa6526c --- /dev/null +++ b/internal/trackers/impl/standalone/thr/definition.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, assets) + return trackers.DescriptionResult{Group: "thr", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/thr/dupe.go b/internal/trackers/impl/standalone/thr/dupe.go new file mode 100644 index 00000000..4d12d4f4 --- /dev/null +++ b/internal/trackers/impl/standalone/thr/dupe.go @@ -0,0 +1,143 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + xhtml "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/pkg/api" +) + +var thrNamePattern = regexp.MustCompile(`overlibImage\('(.+?)','/images`) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if meta.Identity.IMDBID == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing IMDb ID for THR dupe search", nil) + } + trackerCfg, ok := thrConfig(s.cfg) + if !ok || strings.TrimSpace(trackerCfg.Username) == "" || strings.TrimSpace(trackerCfg.Password) == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing THR username/password", nil) + } + baseURL := thrBaseURL(trackerCfg) + sessionCookies, err := thrLogin(ctx, s.http, baseURL, trackerCfg.Username, trackerCfg.Password) + if err != nil { + return dupe.Failed(dupe.FailureAuthentication, "THR login failed", err) + } + entries := make([]api.DupeEntry, 0) + for page := 0; page <= 10; page++ { + params := url.Values{ + "search": {fmt.Sprintf("tt%07d", meta.Identity.IMDBID)}, + "blah": {"2"}, + "incldead": {"1"}, + } + if page > 0 { + params.Set("page", strconv.Itoa(page)) + } + status, root, err := commonhttp.GetHTML(ctx, s.http, baseURL+"/browse.php", params, sessionCookies) + if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices || root == nil { + return dupe.Failed(dupe.FailureRequest, "THR search failed", err) + } + before := len(entries) + for _, link := range commonhttp.FindNodes(root, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.HasPrefix(commonhttp.Attr(node, "href"), "details.php") + }) { + name := thrName(commonhttp.Attr(link, "onmousemove")) + if name == "" { + name = strings.TrimSpace(commonhttp.NodeText(link)) + } + if name != "" { + entries = append(entries, api.DupeEntry{Name: name, Link: commonhttp.AbsoluteURL(baseURL, commonhttp.Attr(link, "href"))}) + } + } + next := commonhttp.FirstNode(root, func(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "a" && strings.Contains(strings.ToLower(commonhttp.NodeText(node)), "next") + }) + if next == nil || len(entries) == before { + break + } + } + return dupe.Resolved(entries, nil) +} + +func thrLogin(ctx context.Context, client *http.Client, baseURL, username, password string) ([]*http.Cookie, error) { + status, root, err := commonhttp.GetHTML(ctx, client, baseURL+"/login.php", nil, nil) + if err != nil { + return nil, fmt.Errorf("fetch login page: %w", err) + } + if status < http.StatusOK || status >= http.StatusMultipleChoices || root == nil { + return nil, fmt.Errorf("fetch login page: status=%d", status) + } + form := url.Values{} + for _, input := range commonhttp.FindNodes(root, func(node *xhtml.Node) bool { return node.Type == xhtml.ElementNode && node.Data == "input" }) { + if name := commonhttp.Attr(input, "name"); name != "" { + form.Set(name, commonhttp.Attr(input, "value")) + } + } + form.Set("username", strings.TrimSpace(username)) + form.Set("password", strings.TrimSpace(password)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/takelogin.php", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: create THR login request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", baseURL+"/login.php") + req.Header.Set("User-Agent", "upbrr") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("trackers: submit THR login: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + if len(resp.Cookies()) == 0 { + return nil, errors.New("no cookies returned") + } + return resp.Cookies(), nil +} + +func thrConfig(cfg config.Config) (config.TrackerConfig, bool) { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "THR") { + return entry, true + } + } + return config.TrackerConfig{}, false +} +func thrBaseURL(_ config.TrackerConfig) string { + return "https://www.torrenthr.org" +} +func thrName(value string) string { + match := thrNamePattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) == 2 { + return strings.TrimSpace(match[1]) + } + return "" +} diff --git a/internal/trackers/impl/standalone/thr/profile.go b/internal/trackers/impl/standalone/thr/profile.go new file mode 100644 index 00000000..bd3036d8 --- /dev/null +++ b/internal/trackers/impl/standalone/thr/profile.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "context" + "errors" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns THR identity, preparation, dupe, auth, and policy behavior, +// including the strict requirement for matching TMDB or IMDb metadata. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "THR", + BaseURL: baseURL, + DescriptionGroup: "thr", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + ImageHostPolicy: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"thr"}, + OwnedHosts: []string{"thr"}, + DisableWithoutAPI: true, + }, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"torrenthr"}}, + AuthCapability: &api.TrackerAuthCapability{ + TrackerID: "THR", + DisplayName: "THR", + AuthKind: "credential_login", + SupportsLogin: true, + SupportsAutoLogin: true, + }, + AuthResolver: resolveAuthSession, + } +} + +// New returns a fresh THR definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } + +func resolveAuthSession(ctx context.Context, cfg config.TrackerConfig, _ string, _ api.TrackerAuthLoginRequest) error { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return errors.New("trackers: THR username/password not configured") + } + if _, err := LoginSession(ctx, cfg); err != nil { + if errors.Is(err, ErrLoginFailed) { + return &trackers.AuthResolutionError{ + Reason: "login failed", + ConfirmedInvalid: true, + Err: err, + } + } + return &trackers.AuthResolutionError{ + Reason: "remote login unavailable", + Transient: true, + Err: err, + } + } + return nil +} diff --git a/internal/trackers/impl/standalone/thr/upload.go b/internal/trackers/impl/standalone/thr/upload.go new file mode 100644 index 00000000..447e22a2 --- /dev/null +++ b/internal/trackers/impl/standalone/thr/upload.go @@ -0,0 +1,501 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://www.torrenthr.org" + loginPagePath = "/login.php" + takeLoginPath = "/takelogin.php" + uploadURL = baseURL + "/takeupload.php" + sourceFlag = "[https://www.torrenthr.org] TorrentHR.org" + loginResponseMaxSize = 1024 * 1024 +) + +// ErrLoginFailed marks a completed THR login exchange that did not produce +// authenticated index-page evidence. +var ErrLoginFailed = errors.New("trackers: THR login failed") + +var ( + subtitleMap = map[string]string{ + "croatian": "1", + "english": "2", + "bosnian": "3", + "serbian": "4", + "slovenian": "5", + } + idPattern = regexp.MustCompile(`id=(\d+)`) +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + questionnaire *api.TrackerQuestionnaire +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, client, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{ + { + FieldName: "tfile", + FileName: state.releaseName + ".torrent", + Path: state.torrentPath, + }, + { + FieldName: "nfo", + FileName: "MEDIAINFO.txt", + Content: []byte(commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath))), + }, + }) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "THR") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, client, body, contentType, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + client *http.Client, + body []byte, + contentType string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: THR build upload request: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: THR upload request: %w", err) + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + + finalURL := "" + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } + if strings.Contains(finalURL, "uploaded=1") { + torrentID := "" + if match := idPattern.FindStringSubmatch(finalURL); len(match) == 2 { + torrentID = match[1] + } + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, finalURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "THR", + TorrentID: torrentID, + TorrentURL: finalURL, + DownloadURL: finalURL, + TorrentPath: artifactPath, + }}}, nil + } + + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "THR", "upload_failure", bodyBytes, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("THR", resp.StatusCode, bodyBytes) +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "THR", + ReleaseName: state.releaseName, + DescriptionGroup: "thr", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "tfile", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, *http.Client, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, assets) + releaseName := resolveName(req.Meta) + if override := strings.TrimSpace(standalone.QuestionnaireAnswers(req.Meta, "THR")["name_override"]); override != "" { + releaseName = override + } + fields := map[string]string{ + "name": releaseName, + "descr": description, + "type": resolveCategory(req.Meta), + "url": imdbURL(req.Meta), + "tube": strings.TrimSpace(req.Meta.ProviderMetadata.TMDB.YouTube), + } + for idx, sub := range resolveSubtitles(req.Meta) { + fields["subs["+strconv.Itoa(idx)+"]"] = sub + } + client, err := login(ctx, req.TrackerConfig) + if err != nil { + return uploadState{}, nil, err + } + return uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: releaseName, + fields: fields, + questionnaire: buildQuestionnaire(req.Meta), + }, client, nil +} + +func login(ctx context.Context, cfg config.TrackerConfig) (*http.Client, error) { + return LoginSession(ctx, cfg) +} + +// LoginSession performs THR's browser-style login flow. It first loads the +// login page into a cookie jar, carries all non-empty hidden form fields into +// the credential POST, follows the redirect to the authenticated page, and +// returns the live client only after the final index page contains a structured +// logout link on the same scheme and hostname. +func LoginSession(ctx context.Context, cfg config.TrackerConfig) (*http.Client, error) { + return loginSessionAt(ctx, cfg, baseURL) +} + +func loginSessionAt(ctx context.Context, cfg config.TrackerConfig, endpoint string) (*http.Client, error) { + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return nil, errors.New("trackers: THR missing username/password") + } + resolvedBaseURL := strings.TrimRight(strings.TrimSpace(endpoint), "/") + if resolvedBaseURL == "" { + resolvedBaseURL = baseURL + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("trackers: THR create login cookie jar: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + loginPageURL := resolvedBaseURL + loginPagePath + pageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, loginPageURL, nil) + if err != nil { + return nil, fmt.Errorf("trackers: THR build login page request: %w", err) + } + pageReq.Header.Set("User-Agent", "upbrr") + pageResp, err := client.Do(pageReq) + if err != nil { + return nil, fmt.Errorf("trackers: THR login page request: %w", err) + } + pageBody, readErr := readLoginResponse(pageResp) + pageResp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("trackers: THR read login page: %w", readErr) + } + if pageResp.StatusCode < 200 || pageResp.StatusCode >= 300 { + return nil, fmt.Errorf("trackers: THR login page failed status=%d", pageResp.StatusCode) + } + + form := hiddenLoginFields(pageBody) + form.Set("username", strings.TrimSpace(cfg.Username)) + form.Set("password", strings.TrimSpace(cfg.Password)) + form.Set("ssl", "yes") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resolvedBaseURL+takeLoginPath, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("trackers: THR build login request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "upbrr") + req.Header.Set("Referer", loginPageURL) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("trackers: THR login request: %w", err) + } + body, readErr := readLoginResponse(resp) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("trackers: THR read login response: %w", readErr) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("%w: status=%d", ErrLoginFailed, resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("trackers: THR login response status=%d", resp.StatusCode) + } + var finalURL *url.URL + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL + } + if !isAuthenticatedLoginPage(finalURL, body) { + return nil, fmt.Errorf("%w: authenticated marker not found", ErrLoginFailed) + } + return client, nil +} + +// isAuthenticatedLoginPage requires THR's post-login index path and a parsed +// logout anchor on the same scheme and hostname. Path fragments or incidental +// response text cannot independently prove authentication. +func isAuthenticatedLoginPage(finalURL *url.URL, body []byte) bool { + if finalURL == nil || !strings.EqualFold(strings.Trim(finalURL.Path, "/"), "index.php") { + return false + } + tokenizer := html.NewTokenizer(bytes.NewReader(body)) + for { + switch tokenizer.Next() { + case html.ErrorToken: + return false + case html.StartTagToken, html.SelfClosingTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "a") { + continue + } + for _, attr := range token.Attr { + if !strings.EqualFold(attr.Key, "href") { + continue + } + candidate, err := url.Parse(strings.TrimSpace(attr.Val)) + if err != nil || !strings.EqualFold(strings.TrimPrefix(candidate.Path, "/"), "logout.php") { + continue + } + if candidate.Scheme != "" && !strings.EqualFold(candidate.Scheme, finalURL.Scheme) { + continue + } + if candidate.Host != "" && !strings.EqualFold(candidate.Hostname(), finalURL.Hostname()) { + continue + } + return true + } + case html.TextToken, html.EndTagToken, html.CommentToken, html.DoctypeToken: + continue + } + } +} + +func readLoginResponse(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, errors.New("empty response") + } + body, err := io.ReadAll(io.LimitReader(resp.Body, loginResponseMaxSize+1)) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if len(body) > loginResponseMaxSize { + return nil, fmt.Errorf("response exceeds %d bytes", loginResponseMaxSize) + } + return body, nil +} + +func hiddenLoginFields(body []byte) url.Values { + fields := url.Values{} + tokenizer := html.NewTokenizer(bytes.NewReader(body)) + for { + switch tokenizer.Next() { + case html.ErrorToken: + return fields + case html.StartTagToken, html.SelfClosingTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "input") { + continue + } + name, value, inputType := "", "", "" + for _, attr := range token.Attr { + switch { + case strings.EqualFold(attr.Key, "name"): + name = strings.TrimSpace(attr.Val) + case strings.EqualFold(attr.Key, "value"): + value = attr.Val + case strings.EqualFold(attr.Key, "type"): + inputType = strings.TrimSpace(attr.Val) + } + } + if strings.EqualFold(inputType, "hidden") && name != "" && value != "" { + fields.Set(name, value) + } + case html.TextToken, html.EndTagToken, html.CommentToken, html.DoctypeToken: + continue + } + } +} + +func buildDescription(meta api.UploadSubject, assets trackers.DescriptionAssets) string { + parts := []string{ + "[quote=Info]", + "Name: " + strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName)), + "", + "Overview: " + strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.EpisodeOverview, meta.ProviderMetadata.TMDB.Overview)), + "", + metautil.FirstNonEmptyTrimmed(meta.Release.Resolution, meta.Release.Source) + " / " + strings.TrimSpace(meta.Type), + "", + "Category: " + categoryName(meta), + } + if tmdb := meta.Identity.TMDBID; tmdb > 0 { + parts = append(parts, fmt.Sprintf("TMDB: https://www.themoviedb.org/%s/%d", strings.ToLower(categoryName(meta)), tmdb)) + } + if imdb := imdbURL(meta); imdb != "" { + parts = append(parts, "IMDb: "+imdb) + } + parts = append(parts, "[/quote]") + if base := strings.TrimSpace(assets.Description); base != "" { + parts = append(parts, base) + } + for _, image := range assets.Screenshots { + raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) + if raw != "" { + parts = append(parts, "[img]"+raw+"[/img]") + } + } + parts = append(parts, `[size=2][url=https://www.torrenthr.org/forums.php?action=viewtopic&topicid=8977]upbrr[/url][/size]`) + return finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n"))) +} + +func buildQuestionnaire(meta api.UploadSubject) *api.TrackerQuestionnaire { + return &api.TrackerQuestionnaire{ + Tracker: "THR", + Fields: []api.TrackerQuestionnaireField{{ + Key: "name_override", + Label: "Upload Name", + Kind: "text", + Value: resolveName(meta), + Required: true, + }}, + } +} + +func resolveName(meta api.UploadSubject) string { + base := strings.ReplaceAll(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename), "DD+", "DDP") + return regexp.MustCompile(`[^0-9a-zA-Z. '\-\[\]]+`).ReplaceAllString(base, " ") +} + +func resolveCategory(meta api.UploadSubject) string { + if _, err := meta.Identity.RequireCategory(); err != nil { + return "" + } + if containsWord(genresText(meta), "documentary") || containsWord(keywordsText(meta), "documentary") { + return "12" + } + switch categoryName(meta) { + case "MOVIE": + if strings.EqualFold(meta.DiscType, "BDMV") { + return "40" + } + if strings.EqualFold(meta.DiscType, "DVD") || strings.EqualFold(meta.DiscType, "HDDVD") { + return "14" + } + if isSD(meta.Release.Resolution) { + return "4" + } + return "17" + case "TV": + if isSD(meta.Release.Resolution) { + return "7" + } + return "34" + default: + if meta.Anime { + return "31" + } + } + return "" +} + +func resolveSubtitles(meta api.UploadSubject) []string { + result := make([]string, 0, len(meta.SubtitleLanguages)) + seen := map[string]struct{}{} + for _, lang := range meta.SubtitleLanguages { + id := subtitleMap[strings.ToLower(strings.TrimSpace(lang))] + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func imdbURL(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.Identity.IMDBID) +} + +func isSD(res string) bool { + return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") +} + +func containsWord(a string, b string) bool { + return strings.Contains(strings.ToLower(a), strings.ToLower(b)) +} + +func categoryName(meta api.UploadSubject) string { + category, err := meta.Identity.RequireCategory() + if err != nil { + return "" + } + if category == api.CanonicalCategoryTV { + return "TV" + } + return "MOVIE" +} + +func genresText(meta api.UploadSubject) string { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Genres, meta.Release.Genre) +} + +func keywordsText(meta api.UploadSubject) string { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) +} diff --git a/internal/trackers/impl/standalone/thr/upload_test.go b/internal/trackers/impl/standalone/thr/upload_test.go new file mode 100644 index 00000000..9216e163 --- /dev/null +++ b/internal/trackers/impl/standalone/thr/upload_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package thr + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/autobrr/upbrr/internal/config" +) + +func TestLoginSessionBootstrapsHiddenFieldsAndFollowsRedirect(t *testing.T) { + t.Parallel() + + handlerErr := make(chan error, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case loginPagePath: + http.SetCookie(w, &http.Cookie{ + Name: "bootstrap", + Value: "ready", + Path: "/", + }) + _, _ = w.Write([]byte(`
`)) + case takeLoginPath: + if err := r.ParseForm(); err != nil { + handlerErr <- fmt.Errorf("parse login form: %w", err) + return + } + if r.FormValue("username") != "user" || r.FormValue("password") != "pass" || r.FormValue("ssl") != "yes" || r.FormValue("token") != "login-token" { + handlerErr <- errors.New("login form did not preserve credentials and hidden fields") + } + if r.Form.Has("blank") { + handlerErr <- errors.New("empty hidden field should not be submitted") + } + if cookie, err := r.Cookie("bootstrap"); err != nil || cookie.Value != "ready" { + handlerErr <- errors.New("login bootstrap cookie missing") + } + if r.Referer() != "http://"+r.Host+loginPagePath { + handlerErr <- fmt.Errorf("unexpected login referer %q", r.Referer()) + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "authenticated", + Path: "/", + }) + http.Redirect(w, r, "/index.php", http.StatusFound) + case "/index.php": + if cookie, err := r.Cookie("session"); err != nil || cookie.Value != "authenticated" { + _, _ = w.Write([]byte(`
`)) + return + } + _, _ = w.Write([]byte(`Logout`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client, err := loginSessionAt(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, server.URL) + if err != nil { + t.Fatalf("LoginSession: %v", err) + } + if client == nil || client.Jar == nil { + t.Fatal("expected authenticated client with cookie jar") + } + close(handlerErr) + for err := range handlerErr { + t.Error(err) + } +} + +func TestLoginSessionRejectsRedirectWithoutAuthenticatedMarker(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case loginPagePath: + _, _ = w.Write([]byte(``)) + case takeLoginPath: + http.Redirect(w, r, "/home.php", http.StatusFound) + case "/home.php": + _, _ = w.Write([]byte(`
`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client, err := loginSessionAt(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, server.URL) + if !errors.Is(err, ErrLoginFailed) { + t.Fatalf("expected ErrLoginFailed, got %v", err) + } + if client != nil { + t.Fatal("expected rejected login to return no client") + } +} + +func TestLoginSessionRejectsWeakAuthenticatedMarkers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + finalPath string + body string + }{ + { + name: "public index path without logout anchor", + finalPath: "/index.php", + body: `
`, + }, + { + name: "incidental logout text off index", + finalPath: "/help.php", + body: `

Read the logout.php troubleshooting guide.

`, + }, + { + name: "logout anchor off index", + finalPath: "/home.php", + body: `Logout`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case loginPagePath: + _, _ = w.Write([]byte(``)) + case takeLoginPath: + http.Redirect(w, r, tt.finalPath, http.StatusFound) + case tt.finalPath: + _, _ = w.Write([]byte(tt.body)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client, err := loginSessionAt(context.Background(), config.TrackerConfig{ + Username: "user", + Password: "pass", + }, server.URL) + if !errors.Is(err, ErrLoginFailed) { + t.Fatalf("expected ErrLoginFailed, got %v", err) + } + if client != nil { + t.Fatal("expected weak marker login to return no client") + } + }) + } +} diff --git a/internal/trackers/impl/standalone/tl/bbcode.go b/internal/trackers/impl/standalone/tl/bbcode.go new file mode 100644 index 00000000..6011f6d4 --- /dev/null +++ b/internal/trackers/impl/standalone/tl/bbcode.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +var ( + codeTagPattern = regexp.MustCompile(`(?is)\[c\](.*?)\[/c\]`) + imageTagPattern = regexp.MustCompile(`(?i)\[img=[\d"x]+\]`) + listItemPattern = regexp.MustCompile(`(?i)\[\*\]`) + hrPattern = regexp.MustCompile(`(?i)\[hr\]`) +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = strings.ReplaceAll(value, "[center]", "
") + value = strings.ReplaceAll(value, "[/center]", "
") + value = listItemPattern.ReplaceAllString(value, "\n[*]") + value = codeTagPattern.ReplaceAllString(value, "[code]$1[/code]") + value = hrPattern.ReplaceAllString(value, "---") + value = imageTagPattern.ReplaceAllString(value, "[img]") + value = strings.NewReplacer("[*] ", "• ", "[*]", "• ", "[note]", "Note: ", "[/note]", "", "[code]", "", "[/code]", "").Replace(value) + value = bbcode.RemoveList(value) + value = bbcode.ConvertComparisonToCentered(value, 1000) + value = bbcode.RemoveSpoiler(value) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/tl/bbcode_test.go b/internal/trackers/impl/standalone/tl/bbcode_test.go new file mode 100644 index 00000000..8455229e --- /dev/null +++ b/internal/trackers/impl/standalone/tl/bbcode_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "strings" + "testing" +) + +func TestFinalizeDescription(t *testing.T) { + input := "[center]x[/center]\n[comparison=A, B]https://img.example/a.png https://img.example/b.png[/comparison]\n[note]n[/note]" + got := finalizeDescription(input) + if !strings.Contains(got, "
x
") { + t.Fatalf("expected center tags converted for TL, got %q", got) + } + if !strings.Contains(got, "Note: n") { + t.Fatalf("expected note tag rewritten for TL, got %q", got) + } + if strings.Contains(got, "[comparison=") { + t.Fatalf("expected comparison converted for TL, got %q", got) + } +} diff --git a/internal/trackers/impl/standalone/tl/definition.go b/internal/trackers/impl/standalone/tl/definition.go new file mode 100644 index 00000000..0bd495cd --- /dev/null +++ b/internal/trackers/impl/standalone/tl/definition.go @@ -0,0 +1,25 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "context" + + "github.com/autobrr/upbrr/internal/trackers" +) + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(trackers.PreparationInput{ + Tracker: req.Tracker, + Meta: req.Meta, + TrackerConfig: req.TrackerConfig, + Runtime: req.Runtime, + Logger: req.Logger, + }, assets) + return trackers.DescriptionResult{Group: "tl", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/tl/description_assets_test.go b/internal/trackers/impl/standalone/tl/description_assets_test.go new file mode 100644 index 00000000..270cc1e9 --- /dev/null +++ b/internal/trackers/impl/standalone/tl/description_assets_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { + t.Parallel() + + assets := trackers.DescriptionAssets{ + Description: "Body token", + MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, + Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, + } + result, err := prepareDescription(context.Background(), trackers.PreparationInput{ + Tracker: "TL", + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{Description: config.DescriptionSettingsConfig{ + DiscMenuHeader: "Disc menu token", + ScreenshotHeader: "Screenshots token", + }}), + Assets: &assets, + }) + if err != nil { + t.Fatalf("build description: %v", err) + } + assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") + + final := trackers.DescriptionAssets{ + Description: " Authoritative final token ", + Final: true, + MenuImages: assets.MenuImages, + } + result, err = prepareDescription(context.Background(), trackers.PreparationInput{Tracker: "TL", Assets: &final}) + if err != nil { + t.Fatalf("build final description: %v", err) + } + if result.Description != "Authoritative final token" { + t.Fatalf("final description = %q", result.Description) + } +} + +func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { + t.Helper() + previous := -1 + for _, token := range tokens { + position := strings.Index(description, token) + if position <= previous { + t.Fatalf("description tokens out of order at %q: %q", token, description) + } + previous = position + } +} diff --git a/internal/trackers/impl/standalone/tl/dupe.go b/internal/trackers/impl/standalone/tl/dupe.go new file mode 100644 index 00000000..acf8bd98 --- /dev/null +++ b/internal/trackers/impl/standalone/tl/dupe.go @@ -0,0 +1,115 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + cfg config.Config + http *http.Client +} + +// newDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func newDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{cfg: cfg, http: httpClient} +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + if !tlConfigured(s.cfg) { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing passkey for tracker", nil) + } + query := strings.TrimSpace(meta.Release.Title) + if query == "" { + query = strings.TrimSpace(meta.ReleaseName) + } + if query == "" { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing title for TL dupe search", nil) + } + endpoint := "https://www.torrentleech.org/torrents/browse/list/query/" + url.PathEscape(query) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "TL search failed", err) + } + req.Header.Set("Accept", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return dupe.Failed(dupe.FailureRequest, "TL search failed", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return dupe.Failed(dupe.FailureResponseStatus, "TL search failed", nil) + } + var payload struct { + Torrents []map[string]any `json:"torrentList"` + } + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return dupe.Failed(dupe.FailureResponseParse, "TL search failed", err) + } + entries := make([]api.DupeEntry, 0, len(payload.Torrents)) + for _, item := range payload.Torrents { + id := tlString(item["fid"]) + entry := api.DupeEntry{ + Name: tlString(item["name"]), + ID: id, + Link: "https://www.torrentleech.org/torrent/" + id, + } + if size := tlInt64(item["size"]); size > 0 { + entry.SizeKnown, entry.SizeBytes = true, size + } + entries = append(entries, entry) + } + return dupe.Resolved(entries, nil) +} + +func tlConfigured(cfg config.Config) bool { + for name, entry := range cfg.Trackers.Trackers { + if strings.EqualFold(strings.TrimSpace(name), "TL") { + return strings.TrimSpace(entry.Passkey) != "" + } + } + return false +} + +func tlString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + default: + return "" + } +} + +func tlInt64(value any) int64 { + switch typed := value.(type) { + case json.Number: + parsed, _ := typed.Int64() + return parsed + case float64: + return int64(typed) + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + return 0 + } +} diff --git a/internal/trackers/impl/standalone/tl/profile.go b/internal/trackers/impl/standalone/tl/profile.go new file mode 100644 index 00000000..df551cb1 --- /dev/null +++ b/internal/trackers/impl/standalone/tl/profile.go @@ -0,0 +1,41 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns TL identity, preparation, dupe, auth, and policy behavior, +// including the strict requirement for matching TMDB or IMDb metadata. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "TL", + BaseURL: baseURL, + DescriptionGroup: "tl", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: sourceFlag}, + AudioPolicy: &trackers.AudioPolicy{AllowBloat: true}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"tracker.tleechreload", "tracker.torrentleech"}, + }, + AuthCapability: standalone.CookieAuthCapability("TL"), + } +} + +// New returns a fresh TL definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/tl/upload.go b/internal/trackers/impl/standalone/tl/upload.go new file mode 100644 index 00000000..33fdb844 --- /dev/null +++ b/internal/trackers/impl/standalone/tl/upload.go @@ -0,0 +1,490 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tl + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/cookies" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://www.torrentleech.org" + apiUploadURL = baseURL + "/torrents/upload/apiupload" + httpUploadURL = baseURL + "/torrents/upload/" + torrentURL = baseURL + "/torrent/" + sourceFlag = "TorrentLeech.org" +) + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + files []commonhttp.FileField + endpoint string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, client, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, state.files) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + announces := announceList(req.TrackerConfig) + artifactPath := "" + if len(announces) > 0 { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "TL") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, client, body, contentType, announces, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + client *http.Client, + body []byte, + contentType string, + announces []string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.endpoint, bytes.NewReader(body)) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TL build upload request: %w", err) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "upbrr") + + resp, err := client.Do(httpReq) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TL upload request: %w", err) + } + defer resp.Body.Close() + successCandidate := resp.StatusCode == http.StatusFound || (req.TrackerConfig.APIUpload && resp.StatusCode >= 200 && resp.StatusCode < 300) + responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, successCandidate, commonhttp.DefaultResponsePreviewBytes) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TL read upload response: %w", err) + } + + torrentID := "" + if req.TrackerConfig.APIUpload { + text := strings.TrimSpace(string(responseBody)) + if _, err := strconv.Atoi(text); err == nil { + torrentID = text + } + } else if resp.StatusCode == http.StatusFound { + torrentID = strings.TrimPrefix(strings.TrimSpace(resp.Header.Get("Location")), "/successfulupload?torrentID=") + } + if torrentID == "" { + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "TL", "upload_failure", responsePreview, ".html") + return api.UploadSummary{}, commonhttp.UploadHTTPError("TL", resp.StatusCode, responsePreview) + } + + urlValue := torrentURL + torrentID + if len(announces) > 0 { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announces[0], urlValue, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "TL", + TorrentID: torrentID, + TorrentURL: urlValue, + DownloadURL: urlValue, + TorrentPath: artifactPath, + }}}, nil +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "TL", + ReleaseName: state.releaseName, + DescriptionGroup: "tl", + Description: state.description, + Endpoint: state.endpoint, + Payload: state.fields, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(ctx context.Context, req trackers.PreparationInput) (uploadState, *http.Client, error) { + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req, assets) + + releaseName := resolveName(req.Meta) + state := uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: releaseName, + } + + if req.TrackerConfig.APIUpload { + state.endpoint = apiUploadURL + state.fields = map[string]string{ + "announcekey": announceKey(req.TrackerConfig), + "category": resolveCategory(req.Meta), + "description": description, + "name": releaseName, + "nonscene": boolWord(!req.Meta.Scene, "on", "off"), + } + category, _ := req.Meta.Identity.RequireCategory() + switch { + case req.Meta.Anime && req.Meta.Identity.MALID > 0: + state.fields["animeid"] = tlAnimeIDURL(req.Meta.Identity.MALID) + case category == api.CanonicalCategoryMovie && req.Meta.Identity.IMDBID > 0: + state.fields["imdb"] = fmt.Sprintf("tt%07d", req.Meta.Identity.IMDBID) + case category == api.CanonicalCategoryTV: + state.fields["tvmazeid"] = strconv.Itoa(req.Meta.Identity.TVmazeID) + if req.Meta.TVPack { + state.fields["tvmazetype"] = "true" + } + } + if req.TrackerConfig.Anon { + state.fields["is_anonymous_upload"] = "on" + } + state.files = []commonhttp.FileField{{ + FieldName: "torrent", + FileName: releaseName + ".torrent", + Path: torrentPath, + }} + return state, &http.Client{Timeout: 30 * time.Second}, nil + } + + state.endpoint = httpUploadURL + state.fields = map[string]string{ + "name": releaseName, + "category": resolveCategory(req.Meta), + "nonscene": boolWord(!req.Meta.Scene, "on", "off"), + "imdbURL": imdbURL(req.Meta), + "tvMazeURL": tvmazeURL(req.Meta), + "igdbURL": "", + "torrentNFO": "0", + "torrentDesc": "1", + "nfotextbox": "", + "torrentComment": "0", + "uploaderComments": "", + "is_anonymous_upload": boolWord(req.TrackerConfig.Anon, "on", "off"), + } + if req.TrackerConfig.ImgRehost { + for idx, shot := range screenshots(assets.Screenshots) { + state.fields["screenshots["+strconv.Itoa(idx)+"]"] = shot + } + } + state.files = []commonhttp.FileField{ + { + FieldName: "torrent", + FileName: "torrent.torrent", + Path: torrentPath, + }, + { + FieldName: "nfo", + FileName: "description.txt", + Content: []byte(description), + }, + } + client, err := cookieClient(ctx, req.Runtime.DBPath) + if err != nil { + return uploadState{}, nil, err + } + return state, client, nil +} + +// tlAnimeIDURL formats TorrentLeech's animeid field. TL names the value as an +// AniList URL, while upbrr's canonical MALID carries the same anime identifier +// for this tracker payload. +func tlAnimeIDURL(malID int) string { + return fmt.Sprintf("https://anilist.co/anime/%d", malID) +} + +func cookieClient(ctx context.Context, dbPath string) (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("trackers: TL create cookie jar: %w", err) + } + client := &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + cookies, err := loadCookies(ctx, dbPath) + if err != nil { + return nil, err + } + target, _ := url.Parse(baseURL) + jar.SetCookies(target, cookies) + return client, nil +} + +func buildDescription(req trackers.PreparationInput, assets trackers.DescriptionAssets) string { + if assets.Final { + return strings.TrimSpace(assets.Description) + } + meta := req.Meta + parts := make([]string, 0, 8) + + // Custom Header + if header := strings.TrimSpace(req.Runtime.Description.CustomDescriptionHeader); header != "" { + parts = append(parts, header) + } + + // Logo + if req.Runtime.Description.AddLogo { + if logo, _ := descriptionunit3d.ResolveLogo(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig()); logo != "" { + parts = append(parts, `
`) + } + } + + // TV Episode details + if strings.TrimSpace(meta.EpisodeOverview) != "" { + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") + parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") + } + + // File information (BDInfo or MediaInfo) + if media := trackers.ReadBDinfoOrMediaInfo(req.Runtime.DBPath, meta); media != "" { + parts = append(parts, media) + } + + // User description + if strings.TrimSpace(assets.Description) != "" { + parts = append(parts, strings.TrimSpace(assets.Description)) + } + + // menu + if len(assets.MenuImages) > 0 { + // header + if header := strings.TrimSpace(req.Runtime.Description.DiscMenuHeader); header != "" { + parts = append(parts, header) + } + // images + if shots := screenshotBlock(assets.MenuImages); shots != "" { + parts = append(parts, shots) + } + } + // Screenshot Header + if header := strings.TrimSpace(req.Runtime.Description.ScreenshotHeader); header != "" { + parts = append(parts, header) + } + + // Tonemapped Header + if tonemapHeader := strings.TrimSpace( + req.Runtime.Description.TonemappedHeader, + ); tonemapHeader != "" && + descriptionunit3d.ShouldIncludeTonemappedHeader(api.NewDescriptionSubject(meta), req.Runtime.DescriptionConfig(), assets.Screenshots) { + parts = append(parts, tonemapHeader) + } + + // screenshots + if shots := screenshotBlock(assets.Screenshots); shots != "" { + parts = append(parts, shots) + } + + // custom user signature + if signature := strings.TrimSpace(req.Runtime.Description.CustomSignature); signature != "" { + parts = append(parts, signature) + } + + // upbrr signature + link, text := descriptionunit3d.UppbrrSignatureLink() + parts = append(parts, fmt.Sprintf("", link, text)) + + // finalize description + finalDescription := finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) + + // Explicit dry runs retain the local diagnostic description artifact. + if req.Intent == trackers.PreparationIntentDryRun { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(meta), "TL", req.Runtime.DBPath, finalDescription, req.Logger) + } + + return finalDescription +} + +func resolveCategory(meta api.UploadSubject) string { + if _, err := meta.Identity.RequireCategory(); err != nil { + return "" + } + if meta.Anime { + return "34" + } + if !isTV(meta) { + if meta.ProviderMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "en") { + return "36" + } + if containsWord(genresText(meta), "Documentary") { + return "29" + } + if meta.Release.Resolution == "2160p" { + return "47" + } + if strings.EqualFold(meta.DiscType, "BDMV") || strings.EqualFold(meta.Type, "REMUX") && strings.EqualFold(meta.Source, "BluRay") { + return "13" + } + if strings.EqualFold(meta.Type, "ENCODE") && strings.EqualFold(meta.Source, "BluRay") { + return "14" + } + if strings.EqualFold(meta.DiscType, "DVD") || strings.Contains(strings.ToUpper(meta.Source), "DVD") && strings.EqualFold(meta.Type, "REMUX") { + return "12" + } + if strings.Contains(strings.ToUpper(meta.Source), "DVD") || strings.EqualFold(meta.Type, "DVDRIP") { + return "11" + } + if strings.Contains(strings.ToUpper(meta.Type), "WEB") { + return "37" + } + if strings.EqualFold(meta.Type, "HDTV") { + return "43" + } + } + if isTV(meta) && meta.ProviderMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "en") { + return "44" + } + if meta.TVPack { + return "27" + } + if isSD(meta.Release.Resolution) { + return "26" + } + return "32" +} + +func resolveName(meta api.UploadSubject) string { + if strings.TrimSpace(meta.SceneName) != "" { + return strings.TrimSpace(meta.SceneName) + } + return strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename)) +} + +func announceKey(cfg config.TrackerConfig) string { + return strings.TrimSpace(cfg.Passkey) +} + +func announceList(cfg config.TrackerConfig) []string { + passkey := strings.TrimSpace(cfg.Passkey) + if passkey == "" { + return nil + } + return []string{ + "https://tracker.torrentleech.org/a/" + passkey + "/announce", + "https://tracker.tleechreload.org/a/" + passkey + "/announce", + } +} + +func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { + values, err := cookies.LoadTrackerHTTPCookies(ctx, dbPath, "TL", "torrentleech.org") + if err != nil { + return values, fmt.Errorf("trackers: TL load cookies: %w", err) + } + return values, nil +} + +func imdbURL(meta api.UploadSubject) string { + if meta.Identity.IMDBID <= 0 { + return "" + } + return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.Identity.IMDBID) +} + +func tvmazeURL(meta api.UploadSubject) string { + if meta.Identity.TVmazeID <= 0 { + return "" + } + return fmt.Sprintf("https://www.tvmaze.com/shows/%d", meta.Identity.TVmazeID) +} + +func screenshots(images []api.ScreenshotImage) []string { + out := make([]string, 0, len(images)) + for _, image := range images { + if raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)); raw != "" { + out = append(out, raw) + } + } + return out +} + +func screenshotBlock(images []api.ScreenshotImage) string { + if len(images) == 0 { + return "" + } + parts := []string{"
"} + for idx, image := range images { + img := metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL) + web := metautil.FirstNonEmptyTrimmed(image.WebURL, img) + if img == "" || web == "" { + continue + } + parts = append(parts, ``) + if (idx+1)%2 == 0 { + parts = append(parts, "

") + } + } + parts = append(parts, "
") + return strings.Join(parts, " ") +} + +func containsWord(a string, b string) bool { + return strings.Contains(strings.ToLower(a), strings.ToLower(b)) +} + +func isSD(res string) bool { + return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") +} + +func boolWord(cond bool, yes string, no string) string { + if cond { + return yes + } + return no +} + +func isTV(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func genresText(meta api.UploadSubject) string { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Genres, meta.Release.Genre) +} diff --git a/internal/trackers/impl/standalone/tvc/bbcode.go b/internal/trackers/impl/standalone/tvc/bbcode.go new file mode 100644 index 00000000..e28aeb0e --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/bbcode.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/bbcode" +) + +func finalizeDescription(value string) string { + value = strings.TrimSpace(bbcode.NormalizeNewlines(value)) + value = bbcode.ConvertPreToCode(value) + value = bbcode.ConvertHideToSpoiler(value) + value = bbcode.ConvertComparisonToCollapse(value, 1000) + return bbcode.RemoveExtraLines(value) +} diff --git a/internal/trackers/impl/standalone/tvc/definition.go b/internal/trackers/impl/standalone/tvc/definition.go new file mode 100644 index 00000000..e901999e --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/definition.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "context" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +func newDuplicateAdapter(dupe.Dependencies) dupe.Adapter { return tvcDuplicateAdapter{} } + +type tvcDuplicateAdapter struct{} + +func (tvcDuplicateAdapter) Search(_ context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + resolution := strings.ToLower(strings.TrimSpace(meta.Release.Resolution)) + if strings.Contains(resolution, "2160") || strings.EqualFold(meta.Type, "REMUX") || strings.TrimSpace(meta.DiscType) != "" { + return dupe.NotRun(dupe.NotRunUnsupportedContent, "TVC disallows UHD, disc, and remux content", nil) + } + return dupe.NotRun(dupe.NotRunManualCheckRequired, "TVC duplicate search requires a manual check", nil) +} + +func prepareDescription(_ context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, req.TrackerConfig, assets) + return trackers.DescriptionResult{Group: "tvc", Description: description}, nil +} diff --git a/internal/trackers/impl/standalone/tvc/profile.go b/internal/trackers/impl/standalone/tvc/profile.go new file mode 100644 index 00000000..28f11c2d --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/profile.go @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns TVC identity, preparation, dupe, and policy behavior, +// including strict metadata, UHD, BDMV-disc, and remux restrictions. +func Profile() standalone.Profile { + return standalone.Profile{ + Name: "TVC", + BaseURL: "https://tvchaosuk.com", + DescriptionGroup: "tvc", + UploadContentMode: trackers.UploadContentModeDescription, + PrepareDescription: prepareDescription, + PrepareUpload: prepareUpload, + NewDuplicateAdapter: newDuplicateAdapter, + Rules: rules(), + MetadataPolicy: &trackers.TrackerMetadataPolicy{ + RequireKnownCategory: true, + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB, trackers.MetadataFieldIMDB}, + Disposition: api.RuleDispositionStrict, + }}, + }, + UploadArtifactPolicy: &trackers.UploadArtifactPolicy{Source: "TVCHAOS"}, + ImageHostPolicy: &trackers.ImageHostPolicy{AllowedHosts: []string{"imgbb", "imgbox", "pixhost", "bam", "onlyimage"}}, + TorrentIdentityPolicy: &trackers.TorrentIdentityPolicy{TrackerURLPatterns: []string{"https://tvchaosuk.com"}}, + } +} + +// New returns a fresh TVC definition from its tracker-local profile. +func New() *standalone.Definition { return standalone.MustNew(Profile()) } diff --git a/internal/trackers/impl/standalone/tvc/rules.go b/internal/trackers/impl/standalone/tvc/rules.go new file mode 100644 index 00000000..ad623f3b --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/rules.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +// rules strictly blocks UHD, BDMV-disc, and remux uploads. +func rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkRules} } + +func checkRules(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + failures := make([]api.RuleFailure, 0, 3) + if strings.EqualFold(trackers.ResolveRuleResolution(meta), "2160p") { + failures = append(failures, trackers.NewRuleFailure( + "uhd_forbidden", + "TVC disallows UHD uploads", + api.RuleDispositionStrict, + )) + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + failures = append(failures, trackers.NewRuleFailure( + "disc_forbidden", + "TVC disallows disc uploads", + api.RuleDispositionStrict, + )) + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "REMUX") { + failures = append(failures, trackers.NewRuleFailure( + "remux_forbidden", + "TVC disallows remux uploads", + api.RuleDispositionStrict, + )) + } + return failures, nil +} diff --git a/internal/trackers/impl/standalone/tvc/upload.go b/internal/trackers/impl/standalone/tvc/upload.go new file mode 100644 index 00000000..70be40b8 --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/upload.go @@ -0,0 +1,488 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" //nolint:depguard // Extracts tracker response URL path basename. + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/autobrr/upbrr/internal/config" + imagehost "github.com/autobrr/upbrr/internal/imagehosting/host" + "github.com/autobrr/upbrr/internal/metadata/metautil" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" + "github.com/autobrr/upbrr/internal/trackers/impl/standalone" + "github.com/autobrr/upbrr/pkg/api" +) + +const ( + baseURL = "https://tvchaosuk.com" + uploadURL = baseURL + "/api/torrents/upload" + sourceFlag = "TVCHAOS" +) + +var categoryMap = map[string]string{ + "comedy": "29", + "current affairs": "45", + "documentary": "5", + "drama": "11", + "entertainment": "14", + "factual": "19", + "foreign": "43", + "kids": "32", + "movies": "44", + "news": "54", + "reality": "52", + "soaps": "30", + "sci-fi": "33", + "sport": "42", + "holding bin": "53", +} + +type uploadState struct { + torrentPath string + description string + releaseName string + fields map[string]string + questionnaire *api.TrackerQuestionnaire + blockedReason string +} + +func prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + state, err := prepareUploadState(ctx, req) + if err != nil { + return trackers.PreparedOperation{}, err + } + preview := buildUploadPreview(state) + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if strings.EqualFold(trackers.ResolveRuleResolution(api.NewRuleSubject(req.Meta)), "2160p") { + return trackers.PreparedOperation{}, errors.New("trackers: TVC disallows UHD uploads") + } + if state.blockedReason != "" { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: TVC %s", state.blockedReason) + } + body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ + FieldName: "torrent", + FileName: filepath.Base(state.torrentPath), + Path: state.torrentPath, + }}) + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) + announceURL := strings.TrimSpace(req.TrackerConfig.AnnounceURL) + artifactPath := "" + if announceURL != "" { + artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.Runtime.DBPath, "TVC") + if err != nil { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %w", err) + } + } + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitPreparedUpload(submitCtx, req, state, body, contentType, apiKey, announceURL, artifactPath) + }, nil), nil +} + +func submitPreparedUpload( + ctx context.Context, + req trackers.PreparationInput, + state uploadState, + body []byte, + contentType string, + apiKey string, + announceURL string, + artifactPath string, +) (api.UploadSummary, error) { + httpReq, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + uploadURL+"?api_token="+url.QueryEscape(apiKey), + bytes.NewReader(body), + ) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TVC build upload request: %s", commonhttp.RedactErrorDetail(err.Error())) + } + httpReq.Header.Set("Content-Type", contentType) + httpReq.Header.Set("User-Agent", "Mozilla/5.0") + + result, err := commonhttp.ExecuteUpload(&http.Client{Timeout: 30 * time.Second}, httpReq, commonhttp.UploadExecutionOptions{ + Tracker: "TVC", + SuccessStatus: func(status int) bool { return status == http.StatusOK }, + }) + if err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TVC execute upload: %w", err) + } + if !result.Success { + _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.Runtime.DBPath, "TVC", "upload_failure", result.Preview, ".txt") + return api.UploadSummary{}, commonhttp.UploadHTTPError("TVC", result.StatusCode, result.Preview) + } + + payload := string(result.Body) + if strings.Contains(payload, "\n") { + payload = strings.SplitN(payload, "\n", 2)[1] + } + var decoded map[string]any + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: TVC decode response: %w", err) + } + dataURL := strings.TrimSpace(fmt.Sprint(decoded["data"])) + torrentID := path.Base(strings.TrimRight(dataURL, "/")) + + if announceURL != "" { + if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announceURL, dataURL, sourceFlag); err != nil { + return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) + } + } + return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ + Tracker: "TVC", + TorrentID: torrentID, + TorrentURL: dataURL, + DownloadURL: dataURL, + TorrentPath: artifactPath, + }}}, nil +} + +func buildUploadPreview(state uploadState) api.TrackerDryRunEntry { + return standalone.BuildPreview(standalone.PreviewSpec{ + Tracker: "TVC", + BlockedReason: state.blockedReason, + ReleaseName: state.releaseName, + DescriptionGroup: "tvc", + Description: state.description, + Endpoint: uploadURL, + Payload: state.fields, + Questionnaire: state.questionnaire, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: state.torrentPath, + Present: strings.TrimSpace(state.torrentPath) != "", + }}, + }) +} + +func prepareUploadState(_ context.Context, req trackers.PreparationInput) (uploadState, error) { + if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { + return uploadState{}, errors.New("trackers: TVC missing api_key") + } + torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.Runtime.DBPath) + if err != nil { + return uploadState{}, fmt.Errorf("trackers: %w", err) + } + assets, err := trackers.PreparedDescriptionAssets(req.Assets) + if err != nil { + trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) + assets = trackers.DescriptionAssets{} + } + description := buildDescription(req.Meta, req.TrackerConfig, assets) + releaseName := resolveName(req.Meta) + if override := strings.TrimSpace(standalone.QuestionnaireAnswers(req.Meta, "TVC")["name_override"]); override != "" { + releaseName = override + } + + fields := map[string]string{ + "name": releaseName, + "description": description, + "mediainfo": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), + "bdinfo": "", + "category_id": resolveCategory(req.Meta), + "type": resolveResolution(req.Meta), + "tmdb": strconv.Itoa(req.Meta.Identity.TMDBID), + "imdb": strconv.Itoa(req.Meta.Identity.IMDBID), + "mal": strconv.Itoa(req.Meta.Identity.MALID), + "igdb": "0", + "anonymous": boolNum(req.TrackerConfig.Anon), + "stream": boolNum(req.Meta.StreamOptimized > 0), + "sd": boolNum(isSD(req.Meta.Release.Resolution)), + "keywords": keywordsText(req.Meta), + "personal_release": boolNum(req.Meta.PersonalRelease), + "internal": "0", + "featured": "0", + "free": "0", + "doubleup": "0", + "sticky": "0", + } + if isTV(req.Meta) { + if isTVCategory(req.Meta) { + fields["tvdb"] = strconv.Itoa(req.Meta.Identity.TVDBID) + } + fields["season_number"] = strconv.Itoa(maxInt(req.Meta.SeasonInt, 0)) + fields["episode_number"] = strconv.Itoa(maxInt(req.Meta.EpisodeInt, 0)) + } + + return uploadState{ + torrentPath: torrentPath, + description: description, + releaseName: releaseName, + fields: fields, + questionnaire: buildQuestionnaire(req.Meta), + blockedReason: validateUpload(req.TrackerConfig, assets), + }, nil +} + +func buildDescription(meta api.UploadSubject, cfg config.TrackerConfig, assets trackers.DescriptionAssets) string { + parts := make([]string, 0, 6) + if logo := strings.TrimSpace(meta.ProviderMetadata.TMDB.Logo); logo != "" { + parts = append(parts, fmt.Sprintf("[center][img=%d]%s[/img][/center]", maxInt(cfg.ImageCount, 300), logo)) + } + if title := strings.TrimSpace(meta.EpisodeTitle); title != "" { + parts = append(parts, "[center][b]Episode Title:[/b] "+title+"[/center]") + } + if overview := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.EpisodeOverview, meta.ProviderMetadata.TMDB.Overview)); overview != "" { + parts = append(parts, "[center]"+overview+"[/center]") + } + if links := externalLinks(meta); links != "" { + parts = append(parts, "[center]"+links+"[/center]") + } + if shots := screenshotBlock(assets.Screenshots, maxInt(cfg.ImageCount, 2)); shots != "" { + parts = append(parts, "[center]"+shots+"[/center]") + } + if base := strings.TrimSpace(assets.Description); base != "" { + parts = append(parts, "[center][b]Notes / Extra Info[/b]\n"+base+"[/center]") + } + return finalizeDescription(strings.TrimSpace(strings.Join(parts, "\n\n"))) +} + +func buildQuestionnaire(meta api.UploadSubject) *api.TrackerQuestionnaire { + return &api.TrackerQuestionnaire{ + Tracker: "TVC", + Fields: []api.TrackerQuestionnaireField{{ + Key: "name_override", + Label: "Upload Name", + Kind: "text", + Value: resolveName(meta), + Required: true, + }}, + } +} + +func validateUpload(cfg config.TrackerConfig, assets trackers.DescriptionAssets) string { + required := maxInt(cfg.ImageCount, 2) + if len(assets.Screenshots) < required { + return fmt.Sprintf("TVC requires at least %d screenshots", required) + } + for _, image := range assets.Screenshots { + host := strings.ToLower(strings.TrimSpace(imagehost.ExtractHost(metautil.FirstNonEmptyTrimmed(image.WebURL, image.ImgURL, image.RawURL)))) + switch host { + case "imgbb", "imgbox", "pixhost", "bam", "onlyimage": + default: + return "TVC screenshots must use an approved image host" + } + } + return "" +} + +func resolveCategory(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "en") && + !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "ga") && + !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "gd") && + !strings.EqualFold(meta.ProviderMetadata.TMDB.OriginalLanguage, "cy") { + return categoryMap["foreign"] + } + genres := strings.ToLower(genresText(meta)) + for key, value := range categoryMap { + if key != "foreign" && strings.Contains(genres, key) { + return value + } + } + if !isTV(meta) { + return categoryMap["movies"] + } + return categoryMap["holding bin"] +} + +func resolveResolution(meta api.UploadSubject) string { + if meta.TVPack { + switch meta.Release.Resolution { + case "1080p", "1080i": + return "HD1080p Pack" + case "720p": + return "HD720p Pack" + default: + return "SD Pack" + } + } + switch meta.Release.Resolution { + case "1080p", "1080i": + return "HD1080p" + case "720p": + return "HD720p" + default: + return "SD" + } +} + +func resolveName(meta api.UploadSubject) string { + typeName := strings.ReplaceAll(meta.Type, "WEBDL", "WEB-DL") + var name string + switch { + case !isTV(meta): + name = fmt.Sprintf( + "%s (%d) [%s %s %s]", + metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), + maxInt(meta.Release.Year, meta.ProviderMetadata.TMDB.Year), + meta.Release.Resolution, + typeName, + videoSuffix(meta.VideoCodec), + ) + case meta.TVPack: + name = fmt.Sprintf( + "%s - Series %d (%d) [%s %s %s]", + metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), + maxInt(meta.SeasonInt, 1), + maxInt(meta.Release.Year, meta.ProviderMetadata.TMDB.Year), + meta.Release.Resolution, + typeName, + videoSuffix(meta.VideoCodec), + ) + default: + name = fmt.Sprintf( + "%s S%02dE%02d [%s %s %s]", + metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), + maxInt(meta.SeasonInt, 1), + maxInt(meta.EpisodeInt, 1), + meta.Release.Resolution, + typeName, + videoSuffix(meta.VideoCodec), + ) + } + if strings.EqualFold(strings.TrimSpace(meta.VideoCodec), "HEVC") { + name = strings.Replace(name, "]", " HEVC]", 1) + } + return appendCountryCode(meta, name) +} + +func appendCountryCode(meta api.UploadSubject, name string) string { + mapping := map[string]string{ + "AT": "AUT", + "AU": "AUS", + "BE": "BEL", + "CA": "CAN", + "CH": "CHE", + "CZ": "CZE", + "DE": "GER", + "DK": "DNK", + "EE": "EST", + "ES": "SPA", + "FI": "FIN", + "FR": "FRA", + "IE": "IRL", + "IS": "ISL", + "IT": "ITA", + "NL": "NLD", + "NO": "NOR", + "NZ": "NZL", + "PL": "POL", + "PT": "POR", + "RU": "RUS", + "SE": "SWE", + } + for _, code := range meta.ProviderMetadata.TMDB.OriginCountry { + if mapped := mapping[strings.ToUpper(strings.TrimSpace(code))]; mapped != "" { + return name + " [" + mapped + "]" + } + } + return name +} + +func externalLinks(meta api.UploadSubject) string { + parts := make([]string, 0, 3) + if category := categoryName(meta); meta.Identity.TMDBID > 0 && category != "" { + parts = append(parts, fmt.Sprintf("[url=https://www.themoviedb.org/%s/%d]TMDB[/url]", strings.ToLower(category), meta.Identity.TMDBID)) + } + if meta.Identity.IMDBID > 0 { + parts = append(parts, fmt.Sprintf("[url=https://www.imdb.com/title/tt%07d]IMDb[/url]", meta.Identity.IMDBID)) + } + if isTVCategory(meta) && meta.Identity.TVDBID > 0 { + parts = append(parts, fmt.Sprintf("[url=https://www.thetvdb.com/?id=%d&tab=series]TVDB[/url]", meta.Identity.TVDBID)) + } + return strings.Join(parts, " | ") +} + +func screenshotBlock(images []api.ScreenshotImage, count int) string { + if len(images) < count { + return "" + } + parts := []string{"[b]Screenshots[/b]"} + for _, image := range images[:count] { + web := metautil.FirstNonEmptyTrimmed(image.WebURL, image.ImgURL, image.RawURL) + img := metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL) + if web == "" || img == "" { + continue + } + parts = append(parts, fmt.Sprintf("[url=%s][img=350]%s[/img][/url]", web, img)) + } + return strings.Join(parts, " ") +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func boolNum(v bool) string { + if v { + return "1" + } + return "0" +} + +func isSD(res string) bool { + return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") +} + +func videoSuffix(codec string) string { + value := strings.ToUpper(strings.TrimSpace(codec)) + if len(value) <= 3 { + return value + } + return value[len(value)-3:] +} + +func isTV(meta api.UploadSubject) bool { + return isTVCategory(meta) +} + +// isTVCategory reports whether TVC payloads may include TVDB-specific fields. +// Only canonical TV identity enables TVDB-specific fields. +func isTVCategory(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryTV +} + +func isMovieCategory(meta api.UploadSubject) bool { + category, err := meta.Identity.RequireCategory() + return err == nil && category == api.CanonicalCategoryMovie +} + +func categoryName(meta api.UploadSubject) string { + if isTV(meta) { + return "TV" + } + if isMovieCategory(meta) { + return "MOVIE" + } + return "" +} + +func genresText(meta api.UploadSubject) string { + return metautil.FirstNonEmptyTrimmed(meta.ProviderMetadata.TMDB.Genres, meta.Release.Genre) +} + +func keywordsText(meta api.UploadSubject) string { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) +} diff --git a/internal/trackers/impl/standalone/tvc/upload_test.go b/internal/trackers/impl/standalone/tvc/upload_test.go new file mode 100644 index 00000000..445988ba --- /dev/null +++ b/internal/trackers/impl/standalone/tvc/upload_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tvc + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestPrepareUploadStateSkipsTVDBForMovie(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "movie.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + state, err := prepareUploadState(context.Background(), trackers.PreparationInput{ + Tracker: "TVC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Movie.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{ + Category: "MOVIE", + TMDBID: 123, + TVDBID: 456, + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Movie", Year: 2025}, + }, + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2025, + Resolution: "1080p", + }, + Type: "WEBDL", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }) + if err != nil { + t.Fatalf("prepare upload state: %v", err) + } + if _, ok := state.fields["tvdb"]; ok { + t.Fatalf("did not expect tvdb for movie payload") + } +} + +func TestPrepareUploadStateIncludesTVDBForTV(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "show.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + state, err := prepareUploadState(context.Background(), trackers.PreparationInput{ + Tracker: "TVC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Show.S02E03.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{ + Category: "TV", + TMDBID: 123, + TVDBID: 456, + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, + }, + Release: api.ReleaseInfo{ + Title: "Show", + Year: 2025, + Resolution: "1080p", + }, + Type: "WEBDL", + SeasonInt: 2, + EpisodeInt: 3, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }) + if err != nil { + t.Fatalf("prepare upload state: %v", err) + } + if got := state.fields["tvdb"]; got != "456" { + t.Fatalf("expected tvdb=456, got %q", got) + } +} + +func TestPrepareUploadStateIncludesTVDBForMediaInfoTV(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "show.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + state, err := prepareUploadState(context.Background(), trackers.PreparationInput{ + Tracker: "TVC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Show.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{ + Category: "TV", + TMDBID: 123, + TVDBID: 456, + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, + }, + Release: api.ReleaseInfo{ + Title: "Show", + Year: 2025, + Resolution: "1080p", + }, + Type: "WEBDL", + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }) + if err != nil { + t.Fatalf("prepare upload state: %v", err) + } + if got := state.fields["tvdb"]; got != "456" { + t.Fatalf("expected tvdb=456, got %q", got) + } +} + +func TestPrepareUploadStateIncludesTVDBForCanonicalTVSeasonPack(t *testing.T) { + tmp := t.TempDir() + torrentPath := filepath.Join(tmp, "season.torrent") + if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { + t.Fatalf("write torrent: %v", err) + } + + state, err := prepareUploadState(context.Background(), trackers.PreparationInput{ + Tracker: "TVC", + Meta: api.UploadSubject{ + SourcePath: filepath.Join(tmp, "Show.S02.mkv"), + TorrentPath: torrentPath, + Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryTV, + TMDBID: 123, + TVDBID: 456, + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, + }, + Release: api.ReleaseInfo{ + Title: "Show", + Year: 2025, + Resolution: "1080p", + }, + Type: "WEBDL", + TVPack: true, + SeasonInt: 2, + }, + TrackerConfig: config.TrackerConfig{APIKey: "token"}, + }) + if err != nil { + t.Fatalf("prepare upload state: %v", err) + } + if got := state.fields["tvdb"]; got != "456" { + t.Fatalf("expected tvdb=456, got %q", got) + } +} diff --git a/internal/trackers/impl/thr/definition.go b/internal/trackers/impl/thr/definition.go deleted file mode 100644 index 3fe255f0..00000000 --- a/internal/trackers/impl/thr/definition.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package thr - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "THR" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, assets) - return trackers.DescriptionResult{Group: "thr", Description: description}, nil -} diff --git a/internal/trackers/impl/thr/upload.go b/internal/trackers/impl/thr/upload.go deleted file mode 100644 index 4e0911d7..00000000 --- a/internal/trackers/impl/thr/upload.go +++ /dev/null @@ -1,466 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package thr - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "maps" - "net/http" - "net/http/cookiejar" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "golang.org/x/net/html" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://www.torrenthr.org" - loginPagePath = "/login.php" - takeLoginPath = "/takelogin.php" - uploadURL = baseURL + "/takeupload.php" - sourceFlag = "[https://www.torrenthr.org] TorrentHR.org" - loginResponseMaxSize = 1024 * 1024 -) - -// ErrLoginFailed marks a completed THR login exchange that did not produce -// authenticated index-page evidence. -var ErrLoginFailed = errors.New("trackers: THR login failed") - -var ( - subtitleMap = map[string]string{"croatian": "1", "english": "2", "bosnian": "3", "serbian": "4", "slovenian": "5"} - idPattern = regexp.MustCompile(`id=(\d+)`) -) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - questionnaire *api.TrackerQuestionnaire -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, client, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{ - {FieldName: "tfile", FileName: state.releaseName + ".torrent", Path: state.torrentPath}, - {FieldName: "nfo", FileName: "MEDIAINFO.txt", Content: []byte(commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)))}, - }) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: THR build upload request: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: THR upload request: %w", err) - } - defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) - - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL.String() - } - if strings.Contains(finalURL, "uploaded=1") { - torrentID := "" - if match := idPattern.FindStringSubmatch(finalURL); len(match) == 2 { - torrentID = match[1] - } - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "THR") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, finalURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "THR", TorrentID: torrentID, TorrentURL: finalURL, DownloadURL: finalURL, TorrentPath: artifactPath, - }}}, nil - } - - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "THR", "upload_failure", bodyBytes, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("THR", resp.StatusCode, bodyBytes) -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - return api.TrackerDryRunEntry{ - Tracker: "THR", - Status: "ready", - Message: "dry-run payload generated", - ReleaseName: state.releaseName, - DescriptionGroup: "thr", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "tfile", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, *http.Client, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, assets) - releaseName := resolveName(req.Meta) - if override := strings.TrimSpace(questionnaireAnswers(req.Meta)["name_override"]); override != "" { - releaseName = override - } - fields := map[string]string{ - "name": releaseName, - "descr": description, - "type": resolveCategory(req.Meta), - "url": imdbURL(req.Meta), - "tube": strings.TrimSpace(req.Meta.ExternalMetadata.TMDB.YouTube), - } - for idx, sub := range resolveSubtitles(req.Meta) { - fields["subs["+strconv.Itoa(idx)+"]"] = sub - } - client, err := login(ctx, req.TrackerConfig) - if err != nil { - return uploadState{}, nil, err - } - return uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: releaseName, - fields: fields, - questionnaire: buildQuestionnaire(req.Meta), - }, client, nil -} - -func login(ctx context.Context, cfg config.TrackerConfig) (*http.Client, error) { - return LoginSession(ctx, cfg) -} - -// LoginSession performs THR's browser-style login flow. It first loads the -// login page into a cookie jar, carries all non-empty hidden form fields into -// the credential POST, follows the redirect to the authenticated page, and -// returns the live client only after the final index page contains a structured -// logout link on the same scheme and hostname. -func LoginSession(ctx context.Context, cfg config.TrackerConfig) (*http.Client, error) { - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { - return nil, errors.New("trackers: THR missing username/password") - } - resolvedBaseURL := strings.TrimRight(strings.TrimSpace(cfg.URL), "/") - if resolvedBaseURL == "" { - resolvedBaseURL = baseURL - } - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("trackers: THR create login cookie jar: %w", err) - } - client := &http.Client{Timeout: 30 * time.Second, Jar: jar} - loginPageURL := resolvedBaseURL + loginPagePath - pageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, loginPageURL, nil) - if err != nil { - return nil, fmt.Errorf("trackers: THR build login page request: %w", err) - } - pageReq.Header.Set("User-Agent", "upbrr") - pageResp, err := client.Do(pageReq) - if err != nil { - return nil, fmt.Errorf("trackers: THR login page request: %w", err) - } - pageBody, readErr := readLoginResponse(pageResp) - pageResp.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("trackers: THR read login page: %w", readErr) - } - if pageResp.StatusCode < 200 || pageResp.StatusCode >= 300 { - return nil, fmt.Errorf("trackers: THR login page failed status=%d", pageResp.StatusCode) - } - - form := hiddenLoginFields(pageBody) - form.Set("username", strings.TrimSpace(cfg.Username)) - form.Set("password", strings.TrimSpace(cfg.Password)) - form.Set("ssl", "yes") - req, err := http.NewRequestWithContext(ctx, http.MethodPost, resolvedBaseURL+takeLoginPath, strings.NewReader(form.Encode())) - if err != nil { - return nil, fmt.Errorf("trackers: THR build login request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "upbrr") - req.Header.Set("Referer", loginPageURL) - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("trackers: THR login request: %w", err) - } - body, readErr := readLoginResponse(resp) - resp.Body.Close() - if readErr != nil { - return nil, fmt.Errorf("trackers: THR read login response: %w", readErr) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("%w: status=%d", ErrLoginFailed, resp.StatusCode) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("trackers: THR login response status=%d", resp.StatusCode) - } - var finalURL *url.URL - if resp.Request != nil && resp.Request.URL != nil { - finalURL = resp.Request.URL - } - if !isAuthenticatedLoginPage(finalURL, body) { - return nil, fmt.Errorf("%w: authenticated marker not found", ErrLoginFailed) - } - return client, nil -} - -// isAuthenticatedLoginPage requires THR's post-login index path and a parsed -// logout anchor on the same scheme and hostname. Path fragments or incidental -// response text cannot independently prove authentication. -func isAuthenticatedLoginPage(finalURL *url.URL, body []byte) bool { - if finalURL == nil || !strings.EqualFold(strings.Trim(finalURL.Path, "/"), "index.php") { - return false - } - tokenizer := html.NewTokenizer(bytes.NewReader(body)) - for { - switch tokenizer.Next() { - case html.ErrorToken: - return false - case html.StartTagToken, html.SelfClosingTagToken: - token := tokenizer.Token() - if !strings.EqualFold(token.Data, "a") { - continue - } - for _, attr := range token.Attr { - if !strings.EqualFold(attr.Key, "href") { - continue - } - candidate, err := url.Parse(strings.TrimSpace(attr.Val)) - if err != nil || !strings.EqualFold(strings.TrimPrefix(candidate.Path, "/"), "logout.php") { - continue - } - if candidate.Scheme != "" && !strings.EqualFold(candidate.Scheme, finalURL.Scheme) { - continue - } - if candidate.Host != "" && !strings.EqualFold(candidate.Hostname(), finalURL.Hostname()) { - continue - } - return true - } - case html.TextToken, html.EndTagToken, html.CommentToken, html.DoctypeToken: - continue - } - } -} - -func readLoginResponse(resp *http.Response) ([]byte, error) { - if resp == nil || resp.Body == nil { - return nil, errors.New("empty response") - } - body, err := io.ReadAll(io.LimitReader(resp.Body, loginResponseMaxSize+1)) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - if len(body) > loginResponseMaxSize { - return nil, fmt.Errorf("response exceeds %d bytes", loginResponseMaxSize) - } - return body, nil -} - -func hiddenLoginFields(body []byte) url.Values { - fields := url.Values{} - tokenizer := html.NewTokenizer(bytes.NewReader(body)) - for { - switch tokenizer.Next() { - case html.ErrorToken: - return fields - case html.StartTagToken, html.SelfClosingTagToken: - token := tokenizer.Token() - if !strings.EqualFold(token.Data, "input") { - continue - } - name, value, inputType := "", "", "" - for _, attr := range token.Attr { - switch { - case strings.EqualFold(attr.Key, "name"): - name = strings.TrimSpace(attr.Val) - case strings.EqualFold(attr.Key, "value"): - value = attr.Val - case strings.EqualFold(attr.Key, "type"): - inputType = strings.TrimSpace(attr.Val) - } - } - if strings.EqualFold(inputType, "hidden") && name != "" && value != "" { - fields.Set(name, value) - } - case html.TextToken, html.EndTagToken, html.CommentToken, html.DoctypeToken: - continue - } - } -} - -func buildDescription(meta api.PreparedMetadata, assets trackers.DescriptionAssets) string { - parts := []string{ - "[quote=Info]", - "Name: " + strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName)), - "", - "Overview: " + strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.EpisodeOverview, meta.ExternalMetadata.TMDB.Overview)), - "", - metautil.FirstNonEmptyTrimmed(meta.Release.Resolution, meta.Release.Source) + " / " + strings.TrimSpace(meta.Type), - "", - "Category: " + categoryName(meta), - } - if tmdb := meta.ExternalIDs.TMDBID; tmdb > 0 { - parts = append(parts, fmt.Sprintf("TMDB: https://www.themoviedb.org/%s/%d", strings.ToLower(categoryName(meta)), tmdb)) - } - if imdb := imdbURL(meta); imdb != "" { - parts = append(parts, "IMDb: "+imdb) - } - parts = append(parts, "[/quote]") - if base := strings.TrimSpace(assets.Description); base != "" { - parts = append(parts, base) - } - for _, image := range assets.Screenshots { - raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)) - if raw != "" { - parts = append(parts, "[img]"+raw+"[/img]") - } - } - parts = append(parts, `[size=2][url=https://www.torrenthr.org/forums.php?action=viewtopic&topicid=8977]upbrr[/url][/size]`) - return bbcode.FinalizeTrackerDescription("THR", strings.TrimSpace(strings.Join(parts, "\n"))) -} - -func buildQuestionnaire(meta api.PreparedMetadata) *api.TrackerQuestionnaire { - return &api.TrackerQuestionnaire{ - Tracker: "THR", - Fields: []api.TrackerQuestionnaireField{{ - Key: "name_override", Label: "Upload Name", Kind: "text", Value: resolveName(meta), Required: true, - }}, - } -} - -func resolveName(meta api.PreparedMetadata) string { - base := strings.ReplaceAll(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename), "DD+", "DDP") - return regexp.MustCompile(`[^0-9a-zA-Z. '\-\[\]]+`).ReplaceAllString(base, " ") -} - -func resolveCategory(meta api.PreparedMetadata) string { - if containsWord(genresText(meta), "documentary") || containsWord(keywordsText(meta), "documentary") { - return "12" - } - switch categoryName(meta) { - case "MOVIE": - if strings.EqualFold(meta.DiscType, "BDMV") { - return "40" - } - if strings.EqualFold(meta.DiscType, "DVD") || strings.EqualFold(meta.DiscType, "HDDVD") { - return "14" - } - if isSD(meta.Release.Resolution) { - return "4" - } - return "17" - case "TV": - if isSD(meta.Release.Resolution) { - return "7" - } - return "34" - default: - if meta.Anime { - return "31" - } - } - return "17" -} - -func resolveSubtitles(meta api.PreparedMetadata) []string { - result := make([]string, 0, len(meta.SubtitleLanguages)) - seen := map[string]struct{}{} - for _, lang := range meta.SubtitleLanguages { - id := subtitleMap[strings.ToLower(strings.TrimSpace(lang))] - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - result = append(result, id) - } - return result -} - -func imdbURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("https://www.imdb.com/title/tt%07d/", meta.ExternalIDs.IMDBID) -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["THR"] -} - -func isSD(res string) bool { - return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") -} - -func containsWord(a string, b string) bool { - return strings.Contains(strings.ToLower(a), strings.ToLower(b)) -} - -func cloneFields(input map[string]string) map[string]string { - out := make(map[string]string, len(input)) - maps.Copy(out, input) - return out -} - -func categoryName(meta api.PreparedMetadata) string { - if isTV(meta) { - return "TV" - } - return "MOVIE" -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} - -func genresText(meta api.PreparedMetadata) string { - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Genres, meta.Release.Genre) -} - -func keywordsText(meta api.PreparedMetadata) string { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) -} diff --git a/internal/trackers/impl/thr/upload_test.go b/internal/trackers/impl/thr/upload_test.go deleted file mode 100644 index 34571a69..00000000 --- a/internal/trackers/impl/thr/upload_test.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package thr - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/autobrr/upbrr/internal/config" -) - -func TestLoginSessionBootstrapsHiddenFieldsAndFollowsRedirect(t *testing.T) { - t.Parallel() - - handlerErr := make(chan error, 8) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case loginPagePath: - http.SetCookie(w, &http.Cookie{Name: "bootstrap", Value: "ready", Path: "/"}) - _, _ = w.Write([]byte(`
`)) - case takeLoginPath: - if err := r.ParseForm(); err != nil { - handlerErr <- fmt.Errorf("parse login form: %w", err) - return - } - if r.FormValue("username") != "user" || r.FormValue("password") != "pass" || r.FormValue("ssl") != "yes" || r.FormValue("token") != "login-token" { - handlerErr <- errors.New("login form did not preserve credentials and hidden fields") - } - if r.Form.Has("blank") { - handlerErr <- errors.New("empty hidden field should not be submitted") - } - if cookie, err := r.Cookie("bootstrap"); err != nil || cookie.Value != "ready" { - handlerErr <- errors.New("login bootstrap cookie missing") - } - if r.Referer() != "http://"+r.Host+loginPagePath { - handlerErr <- fmt.Errorf("unexpected login referer %q", r.Referer()) - } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "authenticated", Path: "/"}) - http.Redirect(w, r, "/index.php", http.StatusFound) - case "/index.php": - if cookie, err := r.Cookie("session"); err != nil || cookie.Value != "authenticated" { - _, _ = w.Write([]byte(`
`)) - return - } - _, _ = w.Write([]byte(`Logout`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - client, err := LoginSession(context.Background(), config.TrackerConfig{URL: server.URL, Username: "user", Password: "pass"}) - if err != nil { - t.Fatalf("LoginSession: %v", err) - } - if client == nil || client.Jar == nil { - t.Fatal("expected authenticated client with cookie jar") - } - close(handlerErr) - for err := range handlerErr { - t.Error(err) - } -} - -func TestLoginSessionRejectsRedirectWithoutAuthenticatedMarker(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case loginPagePath: - _, _ = w.Write([]byte(``)) - case takeLoginPath: - http.Redirect(w, r, "/home.php", http.StatusFound) - case "/home.php": - _, _ = w.Write([]byte(`
`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - client, err := LoginSession(context.Background(), config.TrackerConfig{URL: server.URL, Username: "user", Password: "pass"}) - if !errors.Is(err, ErrLoginFailed) { - t.Fatalf("expected ErrLoginFailed, got %v", err) - } - if client != nil { - t.Fatal("expected rejected login to return no client") - } -} - -func TestLoginSessionRejectsWeakAuthenticatedMarkers(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - finalPath string - body string - }{ - { - name: "public index path without logout anchor", - finalPath: "/index.php", - body: `
`, - }, - { - name: "incidental logout text off index", - finalPath: "/help.php", - body: `

Read the logout.php troubleshooting guide.

`, - }, - { - name: "logout anchor off index", - finalPath: "/home.php", - body: `Logout`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case loginPagePath: - _, _ = w.Write([]byte(``)) - case takeLoginPath: - http.Redirect(w, r, tt.finalPath, http.StatusFound) - case tt.finalPath: - _, _ = w.Write([]byte(tt.body)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - client, err := LoginSession(context.Background(), config.TrackerConfig{URL: server.URL, Username: "user", Password: "pass"}) - if !errors.Is(err, ErrLoginFailed) { - t.Fatalf("expected ErrLoginFailed, got %v", err) - } - if client != nil { - t.Fatal("expected weak marker login to return no client") - } - }) - } -} diff --git a/internal/trackers/impl/tl/definition.go b/internal/trackers/impl/tl/definition.go deleted file mode 100644 index a3f8b210..00000000 --- a/internal/trackers/impl/tl/definition.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tl - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "TL" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(trackers.UploadRequest{ - Tracker: req.Tracker, - Meta: req.Meta, - TrackerConfig: req.TrackerConfig, - AppConfig: req.AppConfig, - Logger: req.Logger, - Repo: req.Repo, - }, assets) - return trackers.DescriptionResult{Group: "tl", Description: description}, nil -} diff --git a/internal/trackers/impl/tl/description_assets_test.go b/internal/trackers/impl/tl/description_assets_test.go deleted file mode 100644 index 97c1f6e5..00000000 --- a/internal/trackers/impl/tl/description_assets_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tl - -import ( - "context" - "strings" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildDescriptionUsesPreparedDiscMenuAssets(t *testing.T) { - t.Parallel() - - assets := trackers.DescriptionAssets{ - Description: "Body token", - MenuImages: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/menu.png"}}, - Screenshots: []api.ScreenshotImage{{RawURL: "https://images.example.invalid/normal.png"}}, - } - result, err := (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{ - Tracker: "TL", - AppConfig: config.Config{Description: config.DescriptionSettingsConfig{ - DiscMenuHeader: "Disc menu token", - ScreenshotHeader: "Screenshots token", - }}, - Assets: &assets, - }) - if err != nil { - t.Fatalf("build description: %v", err) - } - assertDescriptionTokensInOrder(t, result.Description, "Body token", "Disc menu token", "menu.png", "Screenshots token", "normal.png") - - final := trackers.DescriptionAssets{Description: " Authoritative final token ", Final: true, MenuImages: assets.MenuImages} - result, err = (definition{}).BuildDescription(context.Background(), trackers.DescriptionRequest{Tracker: "TL", Assets: &final}) - if err != nil { - t.Fatalf("build final description: %v", err) - } - if result.Description != "Authoritative final token" { - t.Fatalf("final description = %q", result.Description) - } -} - -func assertDescriptionTokensInOrder(t *testing.T, description string, tokens ...string) { - t.Helper() - previous := -1 - for _, token := range tokens { - position := strings.Index(description, token) - if position <= previous { - t.Fatalf("description tokens out of order at %q: %q", token, description) - } - previous = position - } -} diff --git a/internal/trackers/impl/tl/upload.go b/internal/trackers/impl/tl/upload.go deleted file mode 100644 index 3d7b2991..00000000 --- a/internal/trackers/impl/tl/upload.go +++ /dev/null @@ -1,453 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tl - -import ( - "bytes" - "context" - "fmt" - "maps" - "net/http" - "net/http/cookiejar" - "net/url" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/cookies" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://www.torrentleech.org" - apiUploadURL = baseURL + "/torrents/upload/apiupload" - httpUploadURL = baseURL + "/torrents/upload/" - torrentURL = baseURL + "/torrent/" - sourceFlag = "TorrentLeech.org" -) - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - files []commonhttp.FileField - endpoint string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, client, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, state.files) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, state.endpoint, bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TL build upload request: %w", err) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "upbrr") - - resp, err := client.Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TL upload request: %w", err) - } - defer resp.Body.Close() - successCandidate := resp.StatusCode == http.StatusFound || (req.TrackerConfig.APIUpload && resp.StatusCode >= 200 && resp.StatusCode < 300) - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, successCandidate, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TL read upload response: %w", err) - } - - torrentID := "" - if req.TrackerConfig.APIUpload { - text := strings.TrimSpace(string(responseBody)) - if _, err := strconv.Atoi(text); err == nil { - torrentID = text - } - } else if resp.StatusCode == http.StatusFound { - torrentID = strings.TrimPrefix(strings.TrimSpace(resp.Header.Get("Location")), "/successfulupload?torrentID=") - } - if torrentID == "" { - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "TL", "upload_failure", responsePreview, ".html") - return api.UploadSummary{}, commonhttp.UploadHTTPError("TL", resp.StatusCode, responsePreview) - } - - urlValue := torrentURL + torrentID - artifactPath := "" - if announces := announceList(req.TrackerConfig); len(announces) > 0 { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "TL") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announces[0], urlValue, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "TL", TorrentID: torrentID, TorrentURL: urlValue, DownloadURL: urlValue, TorrentPath: artifactPath, - }}}, nil -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, _, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - return api.TrackerDryRunEntry{ - Tracker: "TL", - Status: "ready", - Message: "dry-run payload generated", - ReleaseName: state.releaseName, - DescriptionGroup: "tl", - Description: state.description, - Endpoint: state.endpoint, - Payload: cloneFields(state.fields), - Files: []api.TrackerDryRunFile{{Field: "torrent", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, *http.Client, error) { - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssetsWithPrepared(ctx, req.Tracker, req.Meta, req.Repo, req.Logger, req.Assets) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req, assets) - - releaseName := resolveName(req.Meta) - state := uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: releaseName, - } - - if req.TrackerConfig.APIUpload { - state.endpoint = apiUploadURL - state.fields = map[string]string{ - "announcekey": announceKey(req.TrackerConfig), - "category": resolveCategory(req.Meta), - "description": description, - "name": releaseName, - "nonscene": boolWord(!req.Meta.Scene, "on", "off"), - } - switch { - case req.Meta.Anime && req.Meta.ExternalIDs.MALID > 0: - state.fields["animeid"] = tlAnimeIDURL(req.Meta.ExternalIDs.MALID) - case !isTV(req.Meta) && req.Meta.ExternalIDs.IMDBID > 0: - state.fields["imdb"] = fmt.Sprintf("tt%07d", req.Meta.ExternalIDs.IMDBID) - case isTV(req.Meta): - state.fields["tvmazeid"] = strconv.Itoa(req.Meta.ExternalIDs.TVmazeID) - if req.Meta.TVPack { - state.fields["tvmazetype"] = "true" - } - } - if req.TrackerConfig.Anon { - state.fields["is_anonymous_upload"] = "on" - } - state.files = []commonhttp.FileField{{ - FieldName: "torrent", - FileName: releaseName + ".torrent", - Path: torrentPath, - }} - return state, &http.Client{Timeout: 30 * time.Second}, nil - } - - state.endpoint = httpUploadURL - state.fields = map[string]string{ - "name": releaseName, - "category": resolveCategory(req.Meta), - "nonscene": boolWord(!req.Meta.Scene, "on", "off"), - "imdbURL": imdbURL(req.Meta), - "tvMazeURL": tvmazeURL(req.Meta), - "igdbURL": "", - "torrentNFO": "0", - "torrentDesc": "1", - "nfotextbox": "", - "torrentComment": "0", - "uploaderComments": "", - "is_anonymous_upload": boolWord(req.TrackerConfig.Anon, "on", "off"), - } - if req.TrackerConfig.ImgRehost { - for idx, shot := range screenshots(assets.Screenshots) { - state.fields["screenshots["+strconv.Itoa(idx)+"]"] = shot - } - } - state.files = []commonhttp.FileField{ - {FieldName: "torrent", FileName: "torrent.torrent", Path: torrentPath}, - {FieldName: "nfo", FileName: "description.txt", Content: []byte(description)}, - } - client, err := cookieClient(ctx, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, nil, err - } - return state, client, nil -} - -// tlAnimeIDURL formats TorrentLeech's animeid field. TL names the value as an -// AniList URL, while upbrr's canonical MALID carries the same anime identifier -// for this tracker payload. -func tlAnimeIDURL(malID int) string { - return fmt.Sprintf("https://anilist.co/anime/%d", malID) -} - -func cookieClient(ctx context.Context, dbPath string) (*http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, fmt.Errorf("trackers: TL create cookie jar: %w", err) - } - client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - cookies, err := loadCookies(ctx, dbPath) - if err != nil { - return nil, err - } - target, _ := url.Parse(baseURL) - jar.SetCookies(target, cookies) - return client, nil -} - -func buildDescription(req trackers.UploadRequest, assets trackers.DescriptionAssets) string { - if assets.Final { - return strings.TrimSpace(assets.Description) - } - meta := req.Meta - parts := make([]string, 0, 8) - - // Custom Header - if header := strings.TrimSpace(req.AppConfig.Description.CustomDescriptionHeader); header != "" { - parts = append(parts, header) - } - - // Logo - if req.AppConfig.Description.AddLogo { - if logo, _ := descriptionunit3d.ResolveLogo(meta, req.AppConfig); logo != "" { - parts = append(parts, `
`) - } - } - - // TV Episode details - if strings.TrimSpace(meta.EpisodeOverview) != "" { - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeTitle)+"[/center]") - parts = append(parts, "[center]"+strings.TrimSpace(meta.EpisodeOverview)+"[/center]") - } - - // File information (BDInfo or MediaInfo) - if media := trackers.ReadBDinfoOrMediaInfo(req.AppConfig.MainSettings.DBPath, meta); media != "" { - parts = append(parts, media) - } - - // User description - if strings.TrimSpace(assets.Description) != "" { - parts = append(parts, strings.TrimSpace(assets.Description)) - } - - // menu - if len(assets.MenuImages) > 0 { - // header - if header := strings.TrimSpace(req.AppConfig.Description.DiscMenuHeader); header != "" { - parts = append(parts, header) - } - // images - if shots := screenshotBlock(assets.MenuImages); shots != "" { - parts = append(parts, shots) - } - } - // Screenshot Header - if header := strings.TrimSpace(req.AppConfig.Description.ScreenshotHeader); header != "" { - parts = append(parts, header) - } - - // Tonemapped Header - if tonemapHeader := strings.TrimSpace(req.AppConfig.Description.TonemappedHeader); tonemapHeader != "" && descriptionunit3d.ShouldIncludeTonemappedHeader(meta, req.AppConfig, assets.Screenshots) { - parts = append(parts, tonemapHeader) - } - - // screenshots - if shots := screenshotBlock(assets.Screenshots); shots != "" { - parts = append(parts, shots) - } - - // custom user signature - if signature := strings.TrimSpace(req.AppConfig.Description.CustomSignature); signature != "" { - parts = append(parts, signature) - } - - // upbrr signature - link, text := descriptionunit3d.UppbrrSignatureLink() - parts = append(parts, fmt.Sprintf("", link, text)) - - // finalize description - finalDescription := bbcode.FinalizeTrackerDescription("TL", strings.TrimSpace(strings.Join(parts, "\n\n"))) - - // save debug description - if meta.Options.Debug { - descriptionunit3d.SaveDescriptionDebug(meta, "TL", req.AppConfig.MainSettings.DBPath, finalDescription, req.Logger) - } - - return finalDescription -} - -func resolveCategory(meta api.PreparedMetadata) string { - if meta.Anime { - return "34" - } - if !isTV(meta) { - if meta.ExternalMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "en") { - return "36" - } - if containsWord(genresText(meta), "Documentary") { - return "29" - } - if meta.Release.Resolution == "2160p" { - return "47" - } - if strings.EqualFold(meta.DiscType, "BDMV") || strings.EqualFold(meta.Type, "REMUX") && strings.EqualFold(meta.Source, "BluRay") { - return "13" - } - if strings.EqualFold(meta.Type, "ENCODE") && strings.EqualFold(meta.Source, "BluRay") { - return "14" - } - if strings.EqualFold(meta.DiscType, "DVD") || strings.Contains(strings.ToUpper(meta.Source), "DVD") && strings.EqualFold(meta.Type, "REMUX") { - return "12" - } - if strings.Contains(strings.ToUpper(meta.Source), "DVD") || strings.EqualFold(meta.Type, "DVDRIP") { - return "11" - } - if strings.Contains(strings.ToUpper(meta.Type), "WEB") { - return "37" - } - if strings.EqualFold(meta.Type, "HDTV") { - return "43" - } - } - if isTV(meta) && meta.ExternalMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "en") { - return "44" - } - if meta.TVPack { - return "27" - } - if isSD(meta.Release.Resolution) { - return "26" - } - return "32" -} - -func resolveName(meta api.PreparedMetadata) string { - if strings.TrimSpace(meta.SceneName) != "" { - return strings.TrimSpace(meta.SceneName) - } - return strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.ReleaseName, meta.Release.Title, meta.Filename)) -} - -func announceKey(cfg config.TrackerConfig) string { - return strings.TrimSpace(cfg.Passkey) -} - -func announceList(cfg config.TrackerConfig) []string { - passkey := strings.TrimSpace(cfg.Passkey) - if passkey == "" { - return nil - } - return []string{ - "https://tracker.torrentleech.org/a/" + passkey + "/announce", - "https://tracker.tleechreload.org/a/" + passkey + "/announce", - } -} - -func loadCookies(ctx context.Context, dbPath string) ([]*http.Cookie, error) { - return wrapTrackerResult(cookies.LoadTrackerHTTPCookies(ctx, dbPath, "TL", "torrentleech.org")) -} - -func imdbURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.IMDBID <= 0 { - return "" - } - return fmt.Sprintf("https://www.imdb.com/title/tt%07d", meta.ExternalIDs.IMDBID) -} - -func tvmazeURL(meta api.PreparedMetadata) string { - if meta.ExternalIDs.TVmazeID <= 0 { - return "" - } - return fmt.Sprintf("https://www.tvmaze.com/shows/%d", meta.ExternalIDs.TVmazeID) -} - -func screenshots(images []api.ScreenshotImage) []string { - out := make([]string, 0, len(images)) - for _, image := range images { - if raw := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(image.RawURL, image.ImgURL)); raw != "" { - out = append(out, raw) - } - } - return out -} - -func screenshotBlock(images []api.ScreenshotImage) string { - if len(images) == 0 { - return "" - } - parts := []string{"
"} - for idx, image := range images { - img := metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL) - web := metautil.FirstNonEmptyTrimmed(image.WebURL, img) - if img == "" || web == "" { - continue - } - parts = append(parts, ``) - if (idx+1)%2 == 0 { - parts = append(parts, "

") - } - } - parts = append(parts, "
") - return strings.Join(parts, " ") -} - -func containsWord(a string, b string) bool { - return strings.Contains(strings.ToLower(a), strings.ToLower(b)) -} - -func isSD(res string) bool { - return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") -} - -func boolWord(cond bool, yes string, no string) string { - if cond { - return yes - } - return no -} - -func cloneFields(input map[string]string) map[string]string { - out := make(map[string]string, len(input)) - maps.Copy(out, input) - return out -} - -func isTV(meta api.PreparedMetadata) bool { - return meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 || strings.EqualFold(meta.ExternalIDs.Category, "TV") -} - -func genresText(meta api.PreparedMetadata) string { - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Genres, meta.Release.Genre) -} diff --git a/internal/trackers/impl/tl/wrap_errors.go b/internal/trackers/impl/tl/wrap_errors.go deleted file mode 100644 index 99d45c4e..00000000 --- a/internal/trackers/impl/tl/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tl - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/tvc/definition.go b/internal/trackers/impl/tvc/definition.go deleted file mode 100644 index 4d24e6bb..00000000 --- a/internal/trackers/impl/tvc/definition.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tvc - -import ( - "context" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -type definition struct{} - -func New() trackers.Definition { return definition{} } -func (definition) Name() string { return "TVC" } - -func (definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - return upload(ctx, req) -} - -func (definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - return buildUploadDryRun(ctx, req) -} - -func (definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, req.TrackerConfig, assets) - return trackers.DescriptionResult{Group: "tvc", Description: description}, nil -} diff --git a/internal/trackers/impl/tvc/upload.go b/internal/trackers/impl/tvc/upload.go deleted file mode 100644 index a1bbdec8..00000000 --- a/internal/trackers/impl/tvc/upload.go +++ /dev/null @@ -1,419 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tvc - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "net/http" - "net/url" - "path" //nolint:depguard // Extracts tracker response URL path basename. - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/services/bbcode" - "github.com/autobrr/upbrr/internal/services/imagehost" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/pkg/api" -) - -const ( - baseURL = "https://tvchaosuk.com" - uploadURL = baseURL + "/api/torrents/upload" - sourceFlag = "TVCHAOS" -) - -var categoryMap = map[string]string{ - "comedy": "29", "current affairs": "45", "documentary": "5", "drama": "11", "entertainment": "14", - "factual": "19", "foreign": "43", "kids": "32", "movies": "44", "news": "54", "reality": "52", "soaps": "30", "sci-fi": "33", "sport": "42", "holding bin": "53", -} - -type uploadState struct { - torrentPath string - description string - releaseName string - fields map[string]string - questionnaire *api.TrackerQuestionnaire - blockedReason string -} - -func upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.UploadSummary{}, err - } - if state.blockedReason != "" { - return api.UploadSummary{}, fmt.Errorf("trackers: TVC %s", state.blockedReason) - } - body, contentType, err := commonhttp.BuildMultipartPayload(state.fields, []commonhttp.FileField{{ - FieldName: "torrent", - FileName: filepath.Base(state.torrentPath), - Path: state.torrentPath, - }}) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL+"?api_token="+url.QueryEscape(strings.TrimSpace(req.TrackerConfig.APIKey)), bytes.NewReader(body)) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TVC build upload request: %s", commonhttp.RedactErrorDetail(err.Error())) - } - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("User-Agent", "Mozilla/5.0") - - resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(httpReq) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TVC upload request: %w", err) - } - defer resp.Body.Close() - responseBody, responsePreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode == http.StatusOK, commonhttp.DefaultResponsePreviewBytes) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TVC read upload response: %w", err) - } - if resp.StatusCode != http.StatusOK { - _, _ = commonhttp.WriteFailureArtifact(req.Meta, req.AppConfig.MainSettings.DBPath, "TVC", "upload_failure", responsePreview, ".txt") - return api.UploadSummary{}, commonhttp.UploadHTTPError("TVC", resp.StatusCode, responsePreview) - } - - payload := string(responseBody) - if strings.Contains(payload, "\n") { - payload = strings.SplitN(payload, "\n", 2)[1] - } - var decoded map[string]any - if err := json.Unmarshal([]byte(payload), &decoded); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: TVC decode response: %w", err) - } - dataURL := strings.TrimSpace(fmt.Sprint(decoded["data"])) - torrentID := path.Base(strings.TrimRight(dataURL, "/")) - - artifactPath := "" - if announce := strings.TrimSpace(req.TrackerConfig.AnnounceURL); announce != "" { - artifactPath, err = trackers.ResolveTrackerTorrentArtifactPath(req.Meta, req.AppConfig.MainSettings.DBPath, "TVC") - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - if err := trackers.WritePersonalizedTorrent(state.torrentPath, artifactPath, announce, dataURL, sourceFlag); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - } - return api.UploadSummary{Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{{ - Tracker: "TVC", TorrentID: torrentID, TorrentURL: dataURL, DownloadURL: dataURL, TorrentPath: artifactPath, - }}}, nil -} - -func buildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - state, err := prepareUploadState(ctx, req) - if err != nil { - return api.TrackerDryRunEntry{}, err - } - status := "ready" - message := "dry-run payload generated" - if state.blockedReason != "" { - status = "blocked" - message = state.blockedReason - } - return api.TrackerDryRunEntry{ - Tracker: "TVC", - Status: status, - Message: message, - ReleaseName: state.releaseName, - DescriptionGroup: "tvc", - Description: state.description, - Endpoint: uploadURL, - Payload: cloneFields(state.fields), - Questionnaire: state.questionnaire, - Files: []api.TrackerDryRunFile{{Field: "torrent", Path: state.torrentPath, Present: strings.TrimSpace(state.torrentPath) != ""}}, - }, nil -} - -func prepareUploadState(ctx context.Context, req trackers.UploadRequest) (uploadState, error) { - if strings.TrimSpace(req.TrackerConfig.APIKey) == "" { - return uploadState{}, errors.New("trackers: TVC missing api_key") - } - torrentPath, err := trackers.ResolveUploadTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath) - if err != nil { - return uploadState{}, fmt.Errorf("trackers: %w", err) - } - assets, err := trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) - if err != nil { - trackers.LogDescriptionAssetResolutionFailure(req.Logger, req.Tracker, err) - assets = trackers.DescriptionAssets{} - } - description := buildDescription(req.Meta, req.TrackerConfig, assets) - releaseName := resolveName(req.Meta) - if override := strings.TrimSpace(questionnaireAnswers(req.Meta)["name_override"]); override != "" { - releaseName = override - } - - fields := map[string]string{ - "name": releaseName, - "description": description, - "mediainfo": commonhttp.ReadOptionalFile(strings.TrimSpace(req.Meta.MediaInfoTextPath)), - "bdinfo": "", - "category_id": resolveCategory(req.Meta), - "type": resolveResolution(req.Meta), - "tmdb": strconv.Itoa(req.Meta.ExternalIDs.TMDBID), - "imdb": strconv.Itoa(req.Meta.ExternalIDs.IMDBID), - "mal": strconv.Itoa(req.Meta.ExternalIDs.MALID), - "igdb": "0", - "anonymous": boolNum(req.TrackerConfig.Anon), - "stream": boolNum(req.Meta.StreamOptimized > 0), - "sd": boolNum(isSD(req.Meta.Release.Resolution)), - "keywords": keywordsText(req.Meta), - "personal_release": boolNum(req.Meta.PersonalRelease), - "internal": "0", - "featured": "0", - "free": "0", - "doubleup": "0", - "sticky": "0", - } - if isTV(req.Meta) { - if isTVCategory(req.Meta) { - fields["tvdb"] = strconv.Itoa(req.Meta.ExternalIDs.TVDBID) - } - fields["season_number"] = strconv.Itoa(maxInt(req.Meta.SeasonInt, 0)) - fields["episode_number"] = strconv.Itoa(maxInt(req.Meta.EpisodeInt, 0)) - } - - return uploadState{ - torrentPath: torrentPath, - description: description, - releaseName: releaseName, - fields: fields, - questionnaire: buildQuestionnaire(req.Meta), - blockedReason: validateUpload(req.Meta, req.TrackerConfig, assets), - }, nil -} - -func buildDescription(meta api.PreparedMetadata, cfg config.TrackerConfig, assets trackers.DescriptionAssets) string { - parts := make([]string, 0, 6) - if logo := strings.TrimSpace(meta.ExternalMetadata.TMDB.Logo); logo != "" { - parts = append(parts, fmt.Sprintf("[center][img=%d]%s[/img][/center]", maxInt(cfg.ImageCount, 300), logo)) - } - if title := strings.TrimSpace(meta.EpisodeTitle); title != "" { - parts = append(parts, "[center][b]Episode Title:[/b] "+title+"[/center]") - } - if overview := strings.TrimSpace(metautil.FirstNonEmptyTrimmed(meta.EpisodeOverview, meta.ExternalMetadata.TMDB.Overview)); overview != "" { - parts = append(parts, "[center]"+overview+"[/center]") - } - if links := externalLinks(meta); links != "" { - parts = append(parts, "[center]"+links+"[/center]") - } - if shots := screenshotBlock(assets.Screenshots, maxInt(cfg.ImageCount, 2)); shots != "" { - parts = append(parts, "[center]"+shots+"[/center]") - } - if base := strings.TrimSpace(assets.Description); base != "" { - parts = append(parts, "[center][b]Notes / Extra Info[/b]\n"+base+"[/center]") - } - return bbcode.FinalizeTrackerDescription("TVC", strings.TrimSpace(strings.Join(parts, "\n\n"))) -} - -func buildQuestionnaire(meta api.PreparedMetadata) *api.TrackerQuestionnaire { - return &api.TrackerQuestionnaire{ - Tracker: "TVC", - Fields: []api.TrackerQuestionnaireField{{ - Key: "name_override", Label: "Upload Name", Kind: "text", Value: resolveName(meta), Required: true, - }}, - } -} - -func validateUpload(meta api.PreparedMetadata, cfg config.TrackerConfig, assets trackers.DescriptionAssets) string { - if meta.Release.Resolution == "2160p" || strings.EqualFold(meta.DiscType, "BDMV") || strings.EqualFold(meta.Type, "REMUX") { - return "TVC disallows UHD, disc, and remux uploads" - } - required := maxInt(cfg.ImageCount, 2) - if len(assets.Screenshots) < required { - return fmt.Sprintf("TVC requires at least %d screenshots", required) - } - for _, image := range assets.Screenshots { - host := strings.ToLower(strings.TrimSpace(imagehost.ExtractHost(metautil.FirstNonEmptyTrimmed(image.WebURL, image.ImgURL, image.RawURL)))) - switch host { - case "imgbb", "imgbox", "pixhost", "bam", "onlyimage": - default: - return "TVC screenshots must use an approved image host" - } - } - return "" -} - -func resolveCategory(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB.OriginalLanguage != "" && !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "en") && - !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "ga") && - !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "gd") && - !strings.EqualFold(meta.ExternalMetadata.TMDB.OriginalLanguage, "cy") { - return categoryMap["foreign"] - } - genres := strings.ToLower(genresText(meta)) - for key, value := range categoryMap { - if key != "foreign" && strings.Contains(genres, key) { - return value - } - } - if !isTV(meta) { - return categoryMap["movies"] - } - return categoryMap["holding bin"] -} - -func resolveResolution(meta api.PreparedMetadata) string { - if meta.TVPack { - switch meta.Release.Resolution { - case "1080p", "1080i": - return "HD1080p Pack" - case "720p": - return "HD720p Pack" - default: - return "SD Pack" - } - } - switch meta.Release.Resolution { - case "1080p", "1080i": - return "HD1080p" - case "720p": - return "HD720p" - default: - return "SD" - } -} - -func resolveName(meta api.PreparedMetadata) string { - typeName := strings.ReplaceAll(meta.Type, "WEBDL", "WEB-DL") - var name string - switch { - case !isTV(meta): - name = fmt.Sprintf("%s (%d) [%s %s %s]", metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), maxInt(meta.Release.Year, meta.ExternalMetadata.TMDB.Year), meta.Release.Resolution, typeName, videoSuffix(meta.VideoCodec)) - case meta.TVPack: - name = fmt.Sprintf("%s - Series %d (%d) [%s %s %s]", metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), maxInt(meta.SeasonInt, 1), maxInt(meta.Release.Year, meta.ExternalMetadata.TMDB.Year), meta.Release.Resolution, typeName, videoSuffix(meta.VideoCodec)) - default: - name = fmt.Sprintf("%s S%02dE%02d [%s %s %s]", metautil.FirstNonEmptyTrimmed(meta.Release.Title, meta.ReleaseName), maxInt(meta.SeasonInt, 1), maxInt(meta.EpisodeInt, 1), meta.Release.Resolution, typeName, videoSuffix(meta.VideoCodec)) - } - if strings.EqualFold(strings.TrimSpace(meta.VideoCodec), "HEVC") { - name = strings.Replace(name, "]", " HEVC]", 1) - } - return appendCountryCode(meta, name) -} - -func appendCountryCode(meta api.PreparedMetadata, name string) string { - mapping := map[string]string{"AT": "AUT", "AU": "AUS", "BE": "BEL", "CA": "CAN", "CH": "CHE", "CZ": "CZE", "DE": "GER", "DK": "DNK", "EE": "EST", "ES": "SPA", "FI": "FIN", "FR": "FRA", "IE": "IRL", "IS": "ISL", "IT": "ITA", "NL": "NLD", "NO": "NOR", "NZ": "NZL", "PL": "POL", "PT": "POR", "RU": "RUS", "SE": "SWE"} - for _, code := range meta.ExternalMetadata.TMDB.OriginCountry { - if mapped := mapping[strings.ToUpper(strings.TrimSpace(code))]; mapped != "" { - return name + " [" + mapped + "]" - } - } - return name -} - -func externalLinks(meta api.PreparedMetadata) string { - parts := make([]string, 0, 3) - if meta.ExternalIDs.TMDBID > 0 { - parts = append(parts, fmt.Sprintf("[url=https://www.themoviedb.org/%s/%d]TMDB[/url]", strings.ToLower(categoryName(meta)), meta.ExternalIDs.TMDBID)) - } - if meta.ExternalIDs.IMDBID > 0 { - parts = append(parts, fmt.Sprintf("[url=https://www.imdb.com/title/tt%07d]IMDb[/url]", meta.ExternalIDs.IMDBID)) - } - if isTVCategory(meta) && meta.ExternalIDs.TVDBID > 0 { - parts = append(parts, fmt.Sprintf("[url=https://www.thetvdb.com/?id=%d&tab=series]TVDB[/url]", meta.ExternalIDs.TVDBID)) - } - return strings.Join(parts, " | ") -} - -func screenshotBlock(images []api.ScreenshotImage, count int) string { - if len(images) < count { - return "" - } - parts := []string{"[b]Screenshots[/b]"} - for _, image := range images[:count] { - web := metautil.FirstNonEmptyTrimmed(image.WebURL, image.ImgURL, image.RawURL) - img := metautil.FirstNonEmptyTrimmed(image.ImgURL, image.RawURL) - if web == "" || img == "" { - continue - } - parts = append(parts, fmt.Sprintf("[url=%s][img=350]%s[/img][/url]", web, img)) - } - return strings.Join(parts, " ") -} - -func questionnaireAnswers(meta api.PreparedMetadata) map[string]string { - if len(meta.TrackerQuestionnaireAnswers) == 0 { - return nil - } - return meta.TrackerQuestionnaireAnswers["TVC"] -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func boolNum(v bool) string { - if v { - return "1" - } - return "0" -} - -func isSD(res string) bool { - return strings.HasPrefix(res, "480") || strings.HasPrefix(res, "576") || strings.HasPrefix(res, "540") -} - -func videoSuffix(codec string) string { - value := strings.ToUpper(strings.TrimSpace(codec)) - if len(value) <= 3 { - return value - } - return value[len(value)-3:] -} - -func cloneFields(input map[string]string) map[string]string { - out := make(map[string]string, len(input)) - maps.Copy(out, input) - return out -} - -func isTV(meta api.PreparedMetadata) bool { - return isTVCategory(meta) -} - -// isTVCategory reports whether TVC payloads may include TVDB-specific fields. -// Explicit movie categories suppress TVDB fields; otherwise TVPack, episode data, and MediaInfo TV are enough. -func isTVCategory(meta api.PreparedMetadata) bool { - if isMovieCategory(meta) { - return false - } - return strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "TV") || - strings.EqualFold(strings.TrimSpace(meta.MediaInfoCategory), "TV") || - strings.EqualFold(strings.TrimSpace(meta.Release.Category), "TV") || - meta.TVPack || meta.SeasonInt > 0 || meta.EpisodeInt > 0 -} - -func isMovieCategory(meta api.PreparedMetadata) bool { - return strings.EqualFold(strings.TrimSpace(meta.ExternalIDs.Category), "MOVIE") || - strings.EqualFold(strings.TrimSpace(meta.MediaInfoCategory), "MOVIE") || - strings.EqualFold(strings.TrimSpace(meta.Release.Category), "MOVIE") -} - -func categoryName(meta api.PreparedMetadata) string { - if isTV(meta) { - return "TV" - } - return "MOVIE" -} - -func genresText(meta api.PreparedMetadata) string { - return metautil.FirstNonEmptyTrimmed(meta.ExternalMetadata.TMDB.Genres, meta.Release.Genre) -} - -func keywordsText(meta api.PreparedMetadata) string { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) -} diff --git a/internal/trackers/impl/tvc/upload_test.go b/internal/trackers/impl/tvc/upload_test.go deleted file mode 100644 index 2a20059f..00000000 --- a/internal/trackers/impl/tvc/upload_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package tvc - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func TestPrepareUploadStateSkipsTVDBForMovie(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "movie.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - state, err := prepareUploadState(context.Background(), trackers.UploadRequest{ - Tracker: "TVC", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Movie.mkv"), - TorrentPath: torrentPath, - MediaInfoCategory: "TV", - ExternalIDs: api.ExternalIDs{ - Category: "MOVIE", - TMDBID: 123, - TVDBID: 456, - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Movie", Year: 2025}, - }, - Release: api.ReleaseInfo{Title: "Movie", Year: 2025, Resolution: "1080p"}, - Type: "WEBDL", - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - }) - if err != nil { - t.Fatalf("prepare upload state: %v", err) - } - if _, ok := state.fields["tvdb"]; ok { - t.Fatalf("did not expect tvdb for movie payload") - } -} - -func TestPrepareUploadStateIncludesTVDBForTV(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "show.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - state, err := prepareUploadState(context.Background(), trackers.UploadRequest{ - Tracker: "TVC", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Show.S02E03.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{ - Category: "TV", - TMDBID: 123, - TVDBID: 456, - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, - }, - Release: api.ReleaseInfo{Title: "Show", Year: 2025, Resolution: "1080p"}, - Type: "WEBDL", - SeasonInt: 2, - EpisodeInt: 3, - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - }) - if err != nil { - t.Fatalf("prepare upload state: %v", err) - } - if got := state.fields["tvdb"]; got != "456" { - t.Fatalf("expected tvdb=456, got %q", got) - } -} - -func TestPrepareUploadStateIncludesTVDBForMediaInfoTV(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "show.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - state, err := prepareUploadState(context.Background(), trackers.UploadRequest{ - Tracker: "TVC", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Show.mkv"), - TorrentPath: torrentPath, - MediaInfoCategory: "TV", - ExternalIDs: api.ExternalIDs{TMDBID: 123, TVDBID: 456}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, - }, - Release: api.ReleaseInfo{Title: "Show", Year: 2025, Resolution: "1080p"}, - Type: "WEBDL", - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - }) - if err != nil { - t.Fatalf("prepare upload state: %v", err) - } - if got := state.fields["tvdb"]; got != "456" { - t.Fatalf("expected tvdb=456, got %q", got) - } -} - -func TestPrepareUploadStateIncludesTVDBForSeasonPackWithoutCategory(t *testing.T) { - tmp := t.TempDir() - torrentPath := filepath.Join(tmp, "season.torrent") - if err := os.WriteFile(torrentPath, []byte("dummy"), 0o600); err != nil { - t.Fatalf("write torrent: %v", err) - } - - state, err := prepareUploadState(context.Background(), trackers.UploadRequest{ - Tracker: "TVC", - Meta: api.PreparedMetadata{ - SourcePath: filepath.Join(tmp, "Show.S02.mkv"), - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{ - TMDBID: 123, - TVDBID: 456, - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Show", Year: 2025}, - }, - Release: api.ReleaseInfo{Title: "Show", Year: 2025, Resolution: "1080p"}, - Type: "WEBDL", - TVPack: true, - SeasonInt: 2, - }, - TrackerConfig: config.TrackerConfig{APIKey: "token"}, - }) - if err != nil { - t.Fatalf("prepare upload state: %v", err) - } - if got := state.fields["tvdb"]; got != "456" { - t.Fatalf("expected tvdb=456, got %q", got) - } -} diff --git a/internal/trackers/impl/unit3d/a4k.go b/internal/trackers/impl/unit3d/a4k.go deleted file mode 100644 index 73888bee..00000000 --- a/internal/trackers/impl/unit3d/a4k.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteA4KProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DA4KTypeID, - resolveResolutionID: resolveUnit3DA4KResolutionID, - } -} - -func resolveUnit3DA4KTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "WEBDL": "4", - "ENCODE": "3", - } - return mapping[inferUnit3DType(meta)] -} - -func resolveUnit3DA4KResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/acm.go b/internal/trackers/impl/unit3d/acm.go deleted file mode 100644 index 49f96d65..00000000 --- a/internal/trackers/impl/unit3d/acm.go +++ /dev/null @@ -1,515 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "context" - "fmt" - "regexp" - "slices" - "strconv" - "strings" - - "github.com/autobrr/upbrr/internal/config" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -var ( - acmSceneNFOPattern = regexp.MustCompile(`(?is)\[center\]\[spoiler=Scene NFO:\].*?\[/center\]`) - acmSubtitleCodes = map[string]string{ - "arabic": "Ara", - "ara": "Ara", - "ar": "Ara", - "brazilian portuguese": "Por-BR", - "brazilian": "Por-BR", - "portuguese-br": "Por-BR", - "pt-br": "Por-BR", - "bulgarian": "Bul", - "bul": "Bul", - "bg": "Bul", - "chinese": "Chi", - "chi": "Chi", - "zh": "Chi", - "chinese (simplified)": "Chi", - "chinese (traditional)": "Chi", - "croatian": "Cro", - "hrv": "Cro", - "hr": "Cro", - "scr": "Cro", - "czech": "Cze", - "cze": "Cze", - "cz": "Cze", - "cs": "Cze", - "danish": "Dan", - "dan": "Dan", - "da": "Dan", - "dutch": "Dut", - "dut": "Dut", - "nl": "Dut", - "english": "Eng", - "eng": "Eng", - "en": "Eng", - "english (cc)": "Eng", - "english - sdh": "Eng", - "english - forced": "Eng", - "english (forced)": "Eng", - "en (forced)": "Eng", - "english intertitles": "Eng", - "english (intertitles)": "Eng", - "english - intertitles": "Eng", - "en (intertitles)": "Eng", - "estonian": "Est", - "est": "Est", - "et": "Est", - "finnish": "Fin", - "fin": "Fin", - "fi": "Fin", - "french": "Fre", - "fre": "Fre", - "fr": "Fre", - "german": "Ger", - "ger": "Ger", - "de": "Ger", - "greek": "Gre", - "gre": "Gre", - "el": "Gre", - "hebrew": "Heb", - "heb": "Heb", - "he": "Heb", - "hindi": "Hin", - "hin": "Hin", - "hi": "Hin", - "hungarian": "Hun", - "hun": "Hun", - "hu": "Hun", - "icelandic": "Ice", - "ice": "Ice", - "is": "Ice", - "indonesian": "Ind", - "ind": "Ind", - "id": "Ind", - "italian": "Ita", - "ita": "Ita", - "it": "Ita", - "japanese": "Jpn", - "jpn": "Jpn", - "ja": "Jpn", - "korean": "Kor", - "kor": "Kor", - "ko": "Kor", - "latvian": "Lav", - "lav": "Lav", - "lv": "Lav", - "lithuanian": "Lit", - "lit": "Lit", - "lt": "Lit", - "norwegian": "Nor", - "nor": "Nor", - "no": "Nor", - "persian": "Per", - "fa": "Per", - "far": "Per", - "polish": "Pol", - "pol": "Pol", - "pl": "Pol", - "portuguese": "Por", - "por": "Por", - "pt": "Por", - "romanian": "Rom", - "rum": "Rom", - "ro": "Rom", - "russian": "Rus", - "rus": "Rus", - "ru": "Rus", - "serbian": "Ser", - "srp": "Ser", - "sr": "Ser", - "scc": "Ser", - "slovak": "Slo", - "slo": "Slo", - "sk": "Slo", - "slovenian": "Slv", - "slv": "Slv", - "sl": "Slv", - "spanish": "Spa", - "spa": "Spa", - "es": "Spa", - "swedish": "Swe", - "swe": "Swe", - "sv": "Swe", - "thai": "Tha", - "tha": "Tha", - "th": "Tha", - "turkish": "Tur", - "tur": "Tur", - "tr": "Tur", - "ukrainian": "Ukr", - "ukr": "Ukr", - "uk": "Ukr", - "vietnamese": "Vie", - "vie": "Vie", - "vi": "Vie", - } -) - -func siteACMProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DACMTypeID, - resolveResolutionID: resolveUnit3DACMResolutionID, - applyAdditionalPayload: func(req trackers.UploadRequest, data map[string]string) { - if regionID := numericValue(req.Meta.Region); regionID != "" { - data["region_id"] = regionID - } - if distributorID := numericValue(req.Meta.Distributor); distributorID != "" { - data["distributor_id"] = distributorID - } - }, - } -} - -func resolveUnit3DACMTypeID(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - sizeBucket := acmDiscBucket(meta.SourceSize) - if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && sizeBucket != 25 { - switch sizeBucket { - case 50: - return "3" - case 66: - return "2" - case 100: - return "1" - } - } - switch sizeBucket { - case 25: - return "5" - case 50: - return "4" - } - return "0" - } - - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - switch acmDVDType(meta) { - case "DVD 5": - return "14" - case "DVD 9": - return "16" - default: - return "0" - } - } - - switch acmNonDiscType(meta) { - case "UHD REMUX": - return "12" - case "REMUX": - return "7" - case "WEBDL": - return "9" - case "SDTV": - return "13" - case "HDTV": - return "17" - default: - return "0" - } -} - -func resolveUnit3DACMResolutionID(meta api.PreparedMetadata) string { - switch strings.ToLower(strings.TrimSpace(resolveResolution(meta))) { - case "2160p": - return "1" - case "1080p", "1080i": - return "2" - case "720p": - return "3" - case "576p", "576i": - return "4" - case "480p", "480i": - return "5" - default: - return "10" - } -} - -func buildACMName(meta api.PreparedMetadata) string { - name := baseReleaseName(meta) - if name == "" { - return "" - } - - title := strings.TrimSpace(resolveACMTitle(meta)) - originalTitle := strings.TrimSpace(resolveACMOriginalTitle(meta)) - if title != "" && originalTitle != "" && !strings.EqualFold(title, originalTitle) { - name = strings.Replace(name, title, title+" / "+originalTitle+" \u202A", 1) - } - - audio := strings.TrimSpace(meta.Audio) - if strings.Contains(audio, "AAC") { - normalizedAudio := strings.Join(strings.Fields(audio), " ") - name = strings.Replace(name, normalizedAudio, strings.ReplaceAll(normalizedAudio, "AAC ", "AAC"), 1) - } - name = strings.ReplaceAll(name, "DD+ ", "DD+") - name = strings.ReplaceAll(name, "UHD BluRay REMUX", "Remux") - name = strings.ReplaceAll(name, "BluRay REMUX", "Remux") - name = strings.ReplaceAll(name, "H.265", "HEVC") - name = strings.ReplaceAll(name, " Atmos", "") - - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - source := strings.TrimSpace(meta.Source) - resolution := strings.TrimSpace(resolveResolution(meta)) - if source != "" && resolution != "" { - name = strings.ReplaceAll(name, source+" DVD5", resolution+" DVD "+source) - name = strings.ReplaceAll(name, source+" DVD9", resolution+" DVD "+source) - } - if audio != "" && audio == strings.TrimSpace(meta.Channels) { - name = strings.ReplaceAll(name, audio, "MPEG "+audio) - } - } - - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) + acmSubtitleTag(acmSubtitleCodesFor(meta)) -} - -func buildACMDescription(ctx context.Context, meta api.PreparedMetadata, appConfig config.Config, trackerConfig config.TrackerConfig, logger api.Logger, keptDescription string, menuImages []api.ScreenshotImage, screenshots []api.ScreenshotImage) (string, error) { - base := acmSceneNFOPattern.ReplaceAllString(strings.TrimSpace(keptDescription), "") - base = strings.ReplaceAll(base, "\r\n", "\n") - base = strings.ReplaceAll(base, "[pre]", "[code]") - base = strings.ReplaceAll(base, "[/pre]", "[/code]") - base = strings.ReplaceAll(base, "[hide", "[spoiler") - base = strings.ReplaceAll(base, "[/hide]", "[/spoiler]") - base = convertACMComparisonToCollapse(base, 1000) - base = strings.ReplaceAll(base, "[img]", "[img=300]") - base = descriptionunit3d.AppendDVDVOBMediaInfoBlock(base, meta) - - cfg := appConfig - if cfg.Description.ThumbnailSize <= 0 { - cfg.Description.ThumbnailSize = 350 - } - if len(screenshots) > 0 { - cfg.Description.ScreensPerRow = strconv.Itoa(len(screenshots)) - } else if strings.TrimSpace(cfg.Description.ScreensPerRow) == "" { - cfg.Description.ScreensPerRow = "1" - } - - if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && strings.TrimSpace(meta.ServiceLongName) != "" { - header := fmt.Sprintf( - "[center][b][color=#ff00ff][size=18]This release is sourced from %s and is not transcoded, just remuxed from the direct %s stream[/size][/color][/b][/center]", - strings.TrimSpace(meta.ServiceLongName), - strings.TrimSpace(meta.ServiceLongName), - ) - base = strings.TrimSpace(strings.Join([]string{header, base}, "\n")) - } - - return wrapTrackerResult(descriptionunit3d.BuildDescription(ctx, meta, cfg, trackerConfig, logger, base, menuImages, screenshots)) -} - -func resolveACMKeywords(meta api.PreparedMetadata) string { - raw := resolveKeywords(meta) - if raw == "" { - return "" - } - parts := strings.Split(raw, ",") - filtered := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" || strings.Contains(trimmed, " ") { - continue - } - filtered = append(filtered, trimmed) - if len(filtered) >= 10 { - break - } - } - return strings.Join(filtered, ", ") -} - -func acmDiscBucket(sourceSize int64) int { - if sourceSize <= 0 { - return 100 - } - sizeGiB := float64(sourceSize) / float64(1<<30) - for _, bucket := range []int{25, 50, 66, 100} { - if sizeGiB < float64(bucket) { - return bucket - } - } - return 100 -} - -func acmDVDType(meta api.PreparedMetadata) string { - name := strings.ToUpper(strings.TrimSpace(baseReleaseName(meta))) - switch { - case strings.Contains(name, "DVD5"): - return "DVD 5" - case strings.Contains(name, "DVD9"): - return "DVD 9" - case meta.SourceSize > 0 && meta.SourceSize <= 5*(1<<30): - return "DVD 5" - case meta.SourceSize > 0: - return "DVD 9" - default: - return "" - } -} - -func acmNonDiscType(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(inferUnit3DType(meta)), "REMUX") && strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") { - return "UHD REMUX" - } - switch strings.ToUpper(strings.TrimSpace(inferUnit3DType(meta))) { - case "WEBDL", "WEB-DL": - return "WEBDL" - case "HDTV": - if strings.HasPrefix(strings.TrimSpace(resolveResolution(meta)), "4") || strings.HasPrefix(strings.TrimSpace(resolveResolution(meta)), "5") { - return "SDTV" - } - return "HDTV" - case "REMUX": - return "REMUX" - default: - return "" - } -} - -func resolveACMTitle(meta api.PreparedMetadata) string { - for _, value := range []string{ - meta.Release.Title, - resolveACMTMDBTitle(meta), - resolveACMIMDBTitle(meta), - } { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func resolveACMOriginalTitle(meta api.PreparedMetadata) string { - for _, value := range []string{ - resolveACMTMDBOriginalTitle(meta), - resolveACMTMDBRetrievedAKA(meta), - resolveACMIMDBAKA(meta), - meta.Release.Alt, - } { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return strings.TrimPrefix(trimmed, "AKA ") - } - } - return "" -} - -func acmSubtitleCodesFor(meta api.PreparedMetadata) []string { - out := make([]string, 0, len(meta.SubtitleLanguages)) - seen := make(map[string]struct{}, len(meta.SubtitleLanguages)) - for _, language := range meta.SubtitleLanguages { - key := strings.ToLower(strings.TrimSpace(language)) - if key == "" { - continue - } - code, ok := acmSubtitleCodes[key] - if !ok { - continue - } - if _, exists := seen[code]; exists { - continue - } - seen[code] = struct{}{} - out = append(out, code) - } - return out -} - -func acmSubtitleTag(subtitles []string) string { - if len(subtitles) == 0 { - return " [No subs]" - } - if slices.Contains(subtitles, "Eng") { - return "" - } - if len(subtitles) > 1 { - return " [No Eng subs]" - } - return " [" + subtitles[0] + " subs only]" -} - -// convertACMComparisonToCollapse rewrites UNIT3D comparison blocks into ACM -// spoiler markup while leaving malformed blocks unchanged when no image URLs -// can be paired with the source labels. -func convertACMComparisonToCollapse(value string, maxWidth int) string { - re := regexp.MustCompile(`(?is)\[comparison=[\s\S]*?\[/comparison\]`) - return re.ReplaceAllStringFunc(value, func(block string) string { - parts := strings.SplitN(block, "]", 2) - if len(parts) < 2 { - return block - } - sources := strings.Split(strings.ReplaceAll(strings.TrimPrefix(parts[0], "[comparison="), " ", ""), ",") - if len(sources) == 0 { - return block - } - imgSize := min(maxWidth/len(sources), 350) - body := strings.TrimSuffix(parts[1], "[/comparison]") - fields := strings.Fields(strings.ReplaceAll(body, ",", " ")) - images := make([]string, 0, len(fields)) - for _, field := range fields { - trimmed := strings.TrimSpace(field) - if strings.HasPrefix(strings.ToLower(trimmed), "http://") || strings.HasPrefix(strings.ToLower(trimmed), "https://") { - images = append(images, trimmed) - } - } - if len(images) == 0 { - return block - } - lines := make([]string, 0, len(images)/len(sources)+1) - row := make([]string, 0, len(sources)) - for _, image := range images { - row = append(row, fmt.Sprintf("[url=%s][img=%d]%s[/img][/url]", image, imgSize, image)) - if len(row) == len(sources) { - lines = append(lines, strings.Join(row, "")) - row = row[:0] - } - } - if len(row) > 0 { - lines = append(lines, strings.Join(row, "")) - } - return fmt.Sprintf("[spoiler=%s][center]%s[/center]\n%s[/spoiler]", strings.Join(sources, " vs "), strings.Join(sources, " | "), strings.Join(lines, "\n")) - }) -} - -func resolveACMTMDBTitle(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB == nil { - return "" - } - return meta.ExternalMetadata.TMDB.Title -} - -func resolveACMTMDBOriginalTitle(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB == nil { - return "" - } - return meta.ExternalMetadata.TMDB.OriginalTitle -} - -func resolveACMTMDBRetrievedAKA(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB == nil { - return "" - } - return meta.ExternalMetadata.TMDB.RetrievedAKA -} - -func resolveACMIMDBTitle(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB == nil { - return "" - } - return meta.ExternalMetadata.IMDB.Title -} - -func resolveACMIMDBAKA(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB == nil { - return "" - } - return meta.ExternalMetadata.IMDB.AKA -} diff --git a/internal/trackers/impl/unit3d/additional/common_rules.go b/internal/trackers/impl/unit3d/additional/common_rules.go deleted file mode 100644 index 0ebaad80..00000000 --- a/internal/trackers/impl/unit3d/additional/common_rules.go +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package additional - -import ( - "context" - "fmt" - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -var resolutionOrder = map[string]int{ - "480i": 1, - "480p": 2, - "576i": 3, - "576p": 4, - "720p": 5, - "1080i": 6, - "1080p": 7, - "1440p": 8, - "2160p": 9, - "4320p": 10, - "8640p": 11, -} - -func checkLUMEResolution(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - if isDiscType(meta.DiscType) { - return Pass() - } - - resolution := resolveResolution(meta) - if resolution == "" { - return Fail("LUME requires a known resolution") - } - if resolutionOrder[resolution] < resolutionOrder["720p"] { - return Fail("LUME only allows SD releases when the content does not have a higher resolution release.") - } - return Pass() -} - -func checkLUMERequirements(ctx context.Context, meta api.PreparedMetadata, logger api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - if !isDiscType(meta.DiscType) && !strings.EqualFold(strings.TrimSpace(meta.Container), "mkv") { - return Fail("LUME only allows MKV containers for non-disc uploads.") - } - return checkLUMEResolution(ctx, meta, logger) -} - -func checkBHDRequirements(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - switch resolveType(meta) { - case "REMUX", "ENCODE", "WEBDL", "WEBRIP": - container := strings.ToLower(strings.TrimSpace(meta.Container)) - if container != "" && container != "mkv" && container != "mp4" { - return Fail(fmt.Sprintf("Container %q is not allowed for %s. Only MKV and MP4 are permitted.", meta.Container, resolveType(meta))) - } - } - return Pass() -} - -func checkBLUContainer(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - if isDiscType(meta.DiscType) { - return Pass() - } - - container := strings.ToLower(strings.TrimSpace(meta.Container)) - if container == "" { - return Pass() - } - - allowed := []string{"mkv"} - typeValue := resolveType(meta) - if typeValue == "HDTV" { - allowed = append(allowed, "ts") - } - if (typeValue == "WEBDL" || typeValue == "HDTV") && isDolbyVisionOnly(meta) { - allowed = append(allowed, "mp4") - } - if containsAny([]string{container}, allowed) { - return Pass() - } - return Fail("BLU requires one of the following containers for this release: " + strings.ToUpper(strings.Join(allowed, ", "))) -} - -func isDolbyVisionOnly(meta api.PreparedMetadata) bool { - if meta.WebDV { - return true - } - hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) - return strings.Contains(hdr, "DV") && !strings.Contains(hdr, "HDR") -} - -func checkOTWGenres(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - genres := collectGenres(meta) - if !containsAny(genres, []string{"animation", "family"}) { - return Fail("Genre does not match Animation or Family for OTW.") - } - - if isAdultContent(meta) { - return Fail("Adult animation not allowed at OTW.") - } - - if containsAny(genres, []string{"reality", "game show", "game-show", "reality tv", "reality television"}) { - return Fail("Reality / Game Show content not allowed at OTW.") - } - - typeValue := resolveType(meta) - group := resolveGroup(meta) - if group != "" && typeValue != "WEBDL" && !isDiscType(meta.DiscType) { - restricted := map[string]bool{"CMRG": true, "EVO": true, "TERMINAL": true, "VISION": true} - if restricted[strings.ToUpper(group)] { - return Fail(fmt.Sprintf("Group %s is only allowed for raw type content at OTW", group)) - } - } - - return Pass() -} - -func checkSHRIRegion(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") && !strings.EqualFold(strings.TrimSpace(meta.DiscType), "HDDVD") { - return Pass() - } - if strings.TrimSpace(meta.Region) == "" { - return Fail("Region required; skipping SHRI.") - } - return Pass() -} - -func checkTTRSubtitleOnly(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - if !containsAny(normalizeStrings(meta.Release.Language), []string{"spanish", "es", "spa"}) { - return Fail("TTR requires at least one Spanish audio or subtitle track.") - } - return Pass() -} - -func checkULCXRules(ctx context.Context, meta api.PreparedMetadata, _ api.Logger) Result { - select { - case <-ctx.Done(): - return Fail(fmt.Errorf("context canceled: %w", ctx.Err()).Error()) - default: - } - - keywords := collectKeywords(meta) - if containsAny(keywords, []string{"concert"}) { - return Fail("Concerts not allowed at ULCX.") - } - - resolution := resolveResolution(meta) - if strings.EqualFold(strings.TrimSpace(meta.VideoCodec), "HEVC") && resolution != "2160p" && !isAnimation(meta) && !isAnime(meta) { - return Fail("This content might not fit HEVC rules for ULCX.") - } - - typeValue := resolveType(meta) - if (typeValue == "ENCODE" || typeValue == "HDTV") && resolutionOrder[resolution] < resolutionOrder["720p"] { - return Fail("Encodes must be at least 720p resolution for ULCX.") - } - - if typeValue == "DVDRIP" { - return Fail("DVDRIPs are not allowed for ULCX.") - } - - return Pass() -} - -func resolveResolution(meta api.PreparedMetadata) string { - resolution := strings.TrimSpace(meta.Release.Resolution) - if resolution == "" { - resolution = detectResolution(meta.ReleaseName) - } - return resolution -} - -func detectResolution(value string) string { - clean := strings.ToLower(value) - for _, candidate := range []string{"8640p", "4320p", "2160p", "1440p", "1080p", "1080i", "720p", "576p", "576i", "480p", "480i"} { - if strings.Contains(clean, candidate) { - return candidate - } - } - return "" -} - -func resolveType(meta api.PreparedMetadata) string { - typeValue := strings.ToUpper(strings.TrimSpace(meta.Type)) - if typeValue == "" { - typeValue = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) - } - return typeValue -} - -func resolveGroup(meta api.PreparedMetadata) string { - if group := strings.TrimSpace(meta.Release.Group); group != "" { - return group - } - return strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-") -} - -func isDiscType(value string) bool { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "BDMV", "DVD", "HDDVD": - return true - default: - return false - } -} - -func isAdultContent(meta api.PreparedMetadata) bool { - candidates := append([]string{}, collectGenres(meta)...) - candidates = append(candidates, collectKeywords(meta)...) - for _, token := range candidates { - switch strings.ToLower(strings.TrimSpace(token)) { - case "adult", "porn", "pornography", "xxx", "erotic": - return true - } - } - return false -} - -func isAnime(meta api.PreparedMetadata) bool { - if meta.ExternalMetadata.TMDB != nil && externalMetadataMatchesCurrentSource(meta) && meta.ExternalMetadata.TMDB.Anime { - return true - } - return containsAny(collectKeywords(meta), []string{"anime"}) -} - -func isAnimation(meta api.PreparedMetadata) bool { - return containsAny(collectGenres(meta), []string{"animation"}) -} - -func collectGenres(meta api.PreparedMetadata) []string { - values := []string{} - values = append(values, splitList(meta.Release.Genre)...) - if meta.ExternalMetadata.TMDB != nil && externalMetadataMatchesCurrentSource(meta) { - values = append(values, splitList(meta.ExternalMetadata.TMDB.Genres)...) - } - if meta.ExternalMetadata.IMDB != nil && externalMetadataMatchesCurrentSource(meta) { - values = append(values, splitList(meta.ExternalMetadata.IMDB.Genres)...) - } - return normalizeStrings(values) -} - -func collectKeywords(meta api.PreparedMetadata) []string { - values := []string{} - if meta.ExternalMetadata.TMDB != nil && externalMetadataMatchesCurrentSource(meta) { - values = append(values, splitList(meta.ExternalMetadata.TMDB.Keywords)...) - } - return normalizeStrings(values) -} - -func externalMetadataMatchesCurrentSource(meta api.PreparedMetadata) bool { - storedSource := strings.TrimSpace(meta.ExternalMetadata.SourcePath) - return storedSource == "" || strings.EqualFold(storedSource, strings.TrimSpace(meta.SourcePath)) -} - -func splitList(value string) []string { - if strings.TrimSpace(value) == "" { - return nil - } - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - out = append(out, trimmed) - } - } - return out -} - -func normalizeStrings(values []string) []string { - out := make([]string, 0, len(values)) - seen := make(map[string]bool, len(values)) - for _, value := range values { - trimmed := strings.ToLower(strings.TrimSpace(value)) - if trimmed == "" || seen[trimmed] { - continue - } - seen[trimmed] = true - out = append(out, trimmed) - } - return out -} - -func containsAny(values []string, targets []string) bool { - if len(values) == 0 || len(targets) == 0 { - return false - } - targetSet := make(map[string]bool, len(targets)) - for _, target := range targets { - targetSet[strings.ToLower(strings.TrimSpace(target))] = true - } - for _, value := range values { - if targetSet[strings.ToLower(strings.TrimSpace(value))] { - return true - } - } - return false -} diff --git a/internal/trackers/impl/unit3d/additional/rules.go b/internal/trackers/impl/unit3d/additional/rules.go deleted file mode 100644 index 133b5557..00000000 --- a/internal/trackers/impl/unit3d/additional/rules.go +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package additional - -import "strings" - -var ( - languagesEnglish = []string{"english", "en", "eng"} - languagesFrench = []string{"french", "fr", "fra", "fre"} - languagesGerman = []string{"german", "ger", "de", "deu", "gsw"} - languagesSpanish = []string{"spanish", "es", "spa"} - languagesNordic = []string{ - "english", - "norwegian", - "norsk", - "no", - "nb", - "nn", - "swedish", - "sv", - "danish", - "da", - "finnish", - "fi", - "icelandic", - "is", - } -) - -var trackerRuleFactories = map[string]func() RuleSet{ - "AITHER": rulesAITHER, - "ANT": rulesANT, - "A4K": rulesA4K, - "BHD": rulesBHD, - "BLU": rulesBLU, - "DP": rulesDP, - "HHD": rulesHHD, - "LST": rulesLST, - "LUME": rulesLUME, - "MNS": rulesMNS, - "OE": rulesOE, - "OTW": rulesOTW, - "RAS": rulesRAS, - "RF": rulesRF, - "RHD": rulesRHD, - "SHRI": rulesSHRI, - "SP": rulesSP, - "STC": rulesSTC, - "TIK": rulesTIK, - "TOS": rulesTOS, - "TTR": rulesTTR, - "ULCX": rulesULCX, - "NBL": rulesNBL, - "ZNTH": rulesZNTH, -} - -func RulesFor(tracker string) (RuleSet, bool) { - key := strings.ToUpper(strings.TrimSpace(tracker)) - factory, ok := trackerRuleFactories[key] - if !ok { - return RuleSet{}, false - } - return factory(), true -} - -func nonDiscEnglishAudioSubsRule() *LanguageRule { - return &LanguageRule{ - Languages: languagesEnglish, - RequireAudio: true, - RequireSubs: true, - AllowOriginal: true, - ApplyIfNonDisc: true, - } -} - -func rulesAITHER() RuleSet { - return RuleSet{ - RequireUniqueID: true, - Language: nonDiscEnglishAudioSubsRule(), - } -} - -func rulesANT() RuleSet { - return RuleSet{RequireMovieOnly: true} -} - -func rulesA4K() RuleSet { - return RuleSet{Language: nonDiscEnglishAudioSubsRule()} -} - -func rulesBHD() RuleSet { - return RuleSet{ - RequireValidMISetting: true, - BlockAdult: true, - AdultMessage: "Porn/xxx is not allowed at BHD.", - ExtraCheck: checkBHDRequirements, - } -} - -func rulesBLU() RuleSet { - return RuleSet{ExtraCheck: checkBLUContainer} -} - -func rulesHHD() RuleSet { - return RuleSet{BlockDVDRip: true} -} - -func rulesLST() RuleSet { - return RuleSet{ - RequireValidMISetting: true, - Language: nonDiscEnglishAudioSubsRule(), - } -} - -func rulesTIK() RuleSet { - return RuleSet{RequireDiscOnly: true} -} - -func rulesNBL() RuleSet { - return RuleSet{ - RequireTVOnly: true, - Language: &LanguageRule{ - Languages: languagesEnglish, - RequireAudio: true, - RequireSubs: true, - AllowOriginal: true, - ApplyIfNonBDMV: true, - }, - } -} - -func rulesDP() RuleSet { - return RuleSet{ - BlockSingleFileFolder: true, - BlockHardcodedSubs: true, - BlockGroupUnlessType: map[string][]string{"EVO": {"WEBDL"}}, - Language: &LanguageRule{ - Languages: languagesNordic, - RequireAudio: true, - RequireSubs: true, - }, - } -} - -func rulesLUME() RuleSet { - return RuleSet{ - RequireValidMISetting: true, - BlockAdult: true, - AdultMessage: "Porn is not allowed on LUME.", - Language: &LanguageRule{ - Languages: languagesEnglish, - RequireAudio: true, - RequireSubs: true, - AllowOriginal: true, - ApplyIfNonDisc: true, - }, - ExtraCheck: checkLUMERequirements, - } -} - -func rulesMNS() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Adult content is not allowed", - } -} - -func rulesOE() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Porn is not allowed", - Language: &LanguageRule{ - Languages: languagesEnglish, - RequireAudio: true, - RequireSubs: true, - ApplyIfNonDisc: true, - }, - } -} - -func rulesOTW() RuleSet { - return RuleSet{ExtraCheck: checkOTWGenres} -} - -func rulesRAS() RuleSet { - return RuleSet{ - Language: &LanguageRule{ - Languages: languagesNordic, - RequireAudio: true, - RequireSubs: true, - }, - } -} - -func rulesRF() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Porn is not allowed", - RequireMovieOnly: true, - } -} - -// rulesRHD requires German audio for every upload type, including full discs -// whose RHD release names do not carry language tags. -func rulesRHD() RuleSet { - return RuleSet{ - BlockAdult: true, - MinResolution: "720p", - Language: &LanguageRule{ - Languages: languagesGerman, - RequireAudio: true, - }, - RequireSceneNFO: true, - } -} - -func rulesSHRI() RuleSet { - return RuleSet{ExtraCheck: checkSHRIRegion} -} - -func rulesSP() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Porn is not allowed", - MinResolution: "1080p", - } -} - -func rulesSTC() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Porn is not allowed", - RequireTVOnly: true, - } -} - -func rulesTOS() RuleSet { - return RuleSet{ - Language: &LanguageRule{ - Languages: languagesFrench, - RequireAudio: true, - RequireSubs: true, - AllowOriginal: true, - }, - RequireSceneNFO: true, - } -} - -func rulesTTR() RuleSet { - return RuleSet{ - Language: &LanguageRule{ - Languages: languagesSpanish, - RequireAudio: true, - RequireSubs: true, - }, - ExtraCheck: checkTTRSubtitleOnly, - } -} - -func rulesULCX() RuleSet { - return RuleSet{ - RequireValidMISetting: true, - BlockDVDRip: true, - Language: &LanguageRule{ - Languages: languagesEnglish, - RequireAudio: true, - RequireSubs: true, - ApplyIfNonDisc: true, - }, - ExtraCheck: checkULCXRules, - } -} - -// rulesZNTH blocks adult uploads using ZNTH's tracker-facing rejection text. -func rulesZNTH() RuleSet { - return RuleSet{ - BlockAdult: true, - AdultMessage: "Porn/xxx is not allowed at ZNTH.", - } -} diff --git a/internal/trackers/impl/unit3d/additional/types.go b/internal/trackers/impl/unit3d/additional/types.go deleted file mode 100644 index f217f19c..00000000 --- a/internal/trackers/impl/unit3d/additional/types.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package additional - -import ( - "context" - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -type Result struct { - Allowed bool - Reason string -} - -func Pass() Result { - return Result{Allowed: true} -} - -func Fail(reason string) Result { - return Result{Allowed: false, Reason: strings.TrimSpace(reason)} -} - -type ExtraCheck func(ctx context.Context, meta api.PreparedMetadata, logger api.Logger) Result - -type LanguageRule struct { - Languages []string - RequireAudio bool - RequireSubs bool - RequireBoth bool - AllowOriginal bool - ApplyIfNonDisc bool - ApplyIfNonBDMV bool -} - -type RuleSet struct { - RequireUniqueID bool - RequireValidMISetting bool - RequireAudioLanguages bool - RequireDiscOnly bool - RequireMovieOnly bool - RequireTVOnly bool - RequireHEVCForTypes []string - MinResolution string - BlockAdult bool - AdultMessage string - Language *LanguageRule - BlockDVDRip bool - BlockExternalSubs bool - BlockSingleFileFolder bool - BlockHardcodedSubs bool - BlockGroups []string - BlockGroupUnlessType map[string][]string - RequireSceneNFO bool - ExtraCheck ExtraCheck -} diff --git a/internal/trackers/impl/unit3d/additional_rules.go b/internal/trackers/impl/unit3d/additional_rules.go new file mode 100644 index 00000000..55e0ee44 --- /dev/null +++ b/internal/trackers/impl/unit3d/additional_rules.go @@ -0,0 +1,150 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +var ruleResolutionOrder = map[string]int{ + "480i": 1, + "480p": 2, + "576i": 3, + "576p": 4, + "720p": 5, + "1080i": 6, + "1080p": 7, + "1440p": 8, + "2160p": 9, + "4320p": 10, + "8640p": 11, +} + +// ResolutionBelow reports whether value ranks below minimum in the Unit3D resolution order. +func ResolutionBelow(value, minimum string) bool { + return ruleResolutionOrder[value] < ruleResolutionOrder[minimum] +} + +// RuleType returns the normalized release type used by site-specific rules. +func RuleType(meta api.RuleSubject) string { + value := strings.ToUpper(strings.TrimSpace(meta.Type)) + if value == "" { + value = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) + } + return value +} + +// RuleGroup returns the parsed release group or the normalized tag fallback. +func RuleGroup(meta api.RuleSubject) string { + if group := strings.TrimSpace(meta.Release.Group); group != "" { + return group + } + return strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-") +} + +// DolbyVisionOnly reports whether the release has Dolby Vision without HDR fallback. +func DolbyVisionOnly(meta api.RuleSubject) bool { + if meta.WebDV { + return true + } + hdr := strings.ToUpper(strings.TrimSpace(meta.HDR)) + return strings.Contains(hdr, "DV") && !strings.Contains(hdr, "HDR") +} + +// RuleGenres returns normalized current-source release and provider genres. +func RuleGenres(meta api.RuleSubject) []string { + values := splitRuleList(meta.Release.Genre) + if ruleMetadataMatchesSource(meta) && meta.ProviderMetadata.TMDB != nil { + values = append(values, splitRuleList(meta.ProviderMetadata.TMDB.Genres)...) + } + if ruleMetadataMatchesSource(meta) && meta.ProviderMetadata.IMDB != nil { + values = append(values, splitRuleList(meta.ProviderMetadata.IMDB.Genres)...) + } + return NormalizeRuleValues(values) +} + +// RuleKeywords returns normalized current-source TMDB keywords. +func RuleKeywords(meta api.RuleSubject) []string { + if !ruleMetadataMatchesSource(meta) || meta.ProviderMetadata.TMDB == nil { + return nil + } + return NormalizeRuleValues(splitRuleList(meta.ProviderMetadata.TMDB.Keywords)) +} + +// AdultContent reports whether current genres or keywords contain an adult marker. +func AdultContent(meta api.RuleSubject) bool { + for _, token := range append(RuleGenres(meta), RuleKeywords(meta)...) { + switch token { + case "adult", "porn", "pornography", "xxx", "erotic": + return true + } + } + return false +} + +// Anime reports whether current metadata classifies the release as anime. +func Anime(meta api.RuleSubject) bool { + if ruleMetadataMatchesSource(meta) && meta.ProviderMetadata.TMDB != nil && meta.ProviderMetadata.TMDB.Anime { + return true + } + return ContainsRuleValue(RuleKeywords(meta), []string{"anime"}) +} + +// Animation reports whether current metadata contains the animation genre. +func Animation(meta api.RuleSubject) bool { + return ContainsRuleValue(RuleGenres(meta), []string{"animation"}) +} + +// NormalizeRuleValues trims, lowercases, and deduplicates values while preserving order. +func NormalizeRuleValues(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + out = append(out, trimmed) + } + return out +} + +// ContainsRuleValue reports whether normalized values and targets intersect. +func ContainsRuleValue(values, targets []string) bool { + if len(values) == 0 || len(targets) == 0 { + return false + } + targetSet := make(map[string]bool, len(targets)) + for _, target := range targets { + targetSet[strings.ToLower(strings.TrimSpace(target))] = true + } + for _, value := range values { + if targetSet[strings.ToLower(strings.TrimSpace(value))] { + return true + } + } + return false +} + +func ruleMetadataMatchesSource(meta api.RuleSubject) bool { + storedSource := strings.TrimSpace(meta.ProviderMetadata.SourcePath) + return storedSource == "" || strings.EqualFold(storedSource, strings.TrimSpace(meta.SourcePath)) +} + +func splitRuleList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/internal/trackers/impl/unit3d/aither.go b/internal/trackers/impl/unit3d/aither.go deleted file mode 100644 index 1182ec29..00000000 --- a/internal/trackers/impl/unit3d/aither.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func BuildAitherName(meta api.PreparedMetadata) string { - name := strings.TrimSpace(meta.ReleaseName) - if name == "" { - name = strings.TrimSpace(meta.ReleaseNameNoTag) - } - if name == "" { - return "" - } - - resolution := resolveResolution(meta) - videoCodec := strings.TrimSpace(meta.VideoCodec) - videoEncode := strings.TrimSpace(meta.VideoEncode) - nameType := strings.ToUpper(strings.TrimSpace(meta.Type)) - source := strings.TrimSpace(meta.Source) - audio := strings.TrimSpace(meta.Audio) - - languages := append([]string{}, meta.Release.Language...) - if len(languages) > 0 && !hasEnglishLanguage(languages) { - foreignLang := strings.ToUpper(strings.TrimSpace(languages[0])) - if nameType == "REMUX" && isDVDSource(source) { - if meta.Release.Year > 0 { - name = strings.Replace(name, formatOptionalInt(meta.Release.Year), formatOptionalInt(meta.Release.Year)+" "+foreignLang, 1) - } - } else if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - if resolution != "" { - name = strings.Replace(name, resolution, foreignLang+" "+resolution, 1) - } - } - } - - if nameType == "DVDRIP" { - source = "DVDRip" - if meta.Source != "" { - name = strings.Replace(name, meta.Source+" ", "", 1) - } - if videoEncode != "" { - name = strings.Replace(name, videoEncode, "", 1) - } - if resolution != "" { - name = strings.Replace(name, source, resolution+" "+source, 1) - } - if audio != "" && videoEncode != "" { - name = strings.Replace(name, audio, audio+videoEncode, 1) - } - } else if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || (nameType == "REMUX" && isDVDSource(source)) { - if resolution != "" && source != "" { - name = strings.Replace(name, source, resolution+" "+source, 1) - } - if audio != "" && videoCodec != "" { - name = strings.Replace(name, audio, videoCodec+" "+audio, 1) - } - } - - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) -} - -func isDVDSource(source string) bool { - value := strings.ToUpper(strings.TrimSpace(source)) - switch value { - case "PAL DVD", "NTSC DVD", "DVD": - return true - default: - return false - } -} diff --git a/internal/trackers/impl/unit3d/aither_profile.go b/internal/trackers/impl/unit3d/aither_profile.go deleted file mode 100644 index 3c4a0aa1..00000000 --- a/internal/trackers/impl/unit3d/aither_profile.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/internal/trackers" -) - -func siteAITHERProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - applyAdditionalPayload: func(req trackers.UploadRequest, data map[string]string) { - hdrValue := strings.ToUpper(strings.TrimSpace(req.Meta.HDR)) - hasHDR10P := strings.Contains(hdrValue, "HDR10+") - - data["dv"] = boolFlag(strings.Contains(hdrValue, "DV")) - if hasHDR10P { - data["hdr10p"] = "1" - return - } - if strings.Contains(hdrValue, "HDR") || strings.Contains(hdrValue, "HLG") { - data["hdr"] = "1" - } - }, - } -} diff --git a/internal/trackers/impl/unit3d/authentication_doc.go b/internal/trackers/impl/unit3d/authentication_doc.go new file mode 100644 index 00000000..8876d639 --- /dev/null +++ b/internal/trackers/impl/unit3d/authentication_doc.go @@ -0,0 +1,22 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +// Unit3D API authentication +// +// UNIT3D documents three API-token transports: an api_token query parameter, +// an api_token form field, or an Authorization header using the Bearer scheme. +// upbrr deliberately uses only Bearer authentication for Unit3D upload, lookup, +// and filter requests. trackerdata.SetUnit3DAPIHeaders trims the configured key, +// sets Authorization when the key is non-empty, and requests JSON responses. +// It does not add api_token to URLs or multipart form data, which keeps tokens out +// of request URLs and upload payload diagnostics. +// +// API keys come from the effective tracker configuration resolved by +// trackerdata.TrackerAPIKey. A missing key leaves the request unauthenticated; +// callers decide whether that is a configuration failure or a request the remote +// server may reject. Credentials, authorization headers, and token-bearing URLs +// must remain excluded from logs, errors, dry-run payloads, and failure artifacts. +// +// Source: https://github.com/HDInnovations/UNIT3D/blob/master/book/src/torrent_api.md diff --git a/internal/trackers/impl/unit3d/blu.go b/internal/trackers/impl/unit3d/blu.go deleted file mode 100644 index a1145a72..00000000 --- a/internal/trackers/impl/unit3d/blu.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteBLUProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DBLUTypeID, - resolveResolutionID: resolveUnit3DBLUResolutionID, - resolveCategoryID: resolveUnit3DBLUCategoryID, - } -} - -func resolveUnit3DBLUCategoryID(meta api.PreparedMetadata) string { - if strings.Contains(strings.ToUpper(strings.TrimSpace(meta.Edition)), "FANRES") { - return "3" - } - return resolveUnit3DCategoryID(meta) -} - -func resolveUnit3DBLUTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "3", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - "ENCODE": "12", - } - return mapping[inferUnit3DType(meta)] -} - -func resolveUnit3DBLUResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "8640p": "10", - "4320p": "11", - "2160p": "1", - "1440p": "2", - "1080p": "2", - "1080i": "3", - "720p": "5", - "576p": "6", - "576i": "7", - "480p": "8", - "480i": "9", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/cbr.go b/internal/trackers/impl/unit3d/cbr.go deleted file mode 100644 index c3014fc0..00000000 --- a/internal/trackers/impl/unit3d/cbr.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "regexp" - "strconv" - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -var cbrCodecReplacer = strings.NewReplacer( - "DD+ ", "DDP", - "DD ", "DD", - "AAC ", "AAC", - "FLAC ", "FLAC", -) - -var cbrAudioMarkerReplacer = strings.NewReplacer( - "Dubbed", "", - "Dual-Audio", "", -) - -var audioTagRegex = regexp.MustCompile(`(?i)-([^.-]+)\.(?:DUAL|MULTI)`) - -var ptbrMap = map[string]struct{}{ - "português": {}, "portuguese": {}, "pt-br": {}, "pt": {}, -} - -func siteCBRProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveCategoryID: resolveUnit3DCBRCategoryID} -} - -func resolveUnit3DCBRCategoryID(meta api.PreparedMetadata) string { - if strings.EqualFold(resolveUnit3DCategory(meta), "TV") && meta.Anime { - return "4" - } - return resolveUnit3DCategoryID(meta) -} - -func BuildCBRName(meta api.PreparedMetadata, customTag string) string { - name := baseReleaseName(meta) - if name == "" { - return "" - } - - name = cbrCodecReplacer.Replace(name) - - category := resolveUnit3DCategory(meta) - if category == "TV" || meta.Anime { - if meta.Release.Year > 0 { - yearStr := strconv.Itoa(meta.Release.Year) - if strings.Contains(name, yearStr) { - name = strings.ReplaceAll(name, "("+yearStr+")", "") - name = strings.ReplaceAll(name, yearStr, "") - name = strings.TrimSpace(name) - } - } - } - - origLang := strings.ToLower(resolveOriginalLanguage(meta)) - aka := "" - if meta.ExternalMetadata.TMDB != nil { - aka = meta.ExternalMetadata.TMDB.RetrievedAKA - } - - _, isPtBR := ptbrMap[origLang] - - if !isPtBR && aka != "" { - name = strings.ReplaceAll(name, aka, "") - name = strings.Join(strings.Fields(name), " ") - } - - if isPtBR && aka != "" { - akaClean := strings.TrimSpace(strings.ReplaceAll(aka, "AKA", "")) - title := meta.Release.Title - - name = strings.ReplaceAll(name, aka, "") - name = strings.ReplaceAll(name, strings.ReplaceAll(aka, " ", "."), "") - name = strings.ReplaceAll(name, title, akaClean) - name = strings.ReplaceAll(name, strings.ReplaceAll(title, " ", "."), akaClean) - name = strings.Join(strings.Fields(name), " ") - } - - cbrName := name - - if !isDiscType(meta.DiscType) { - audioTag := "" - hasPortuguese := false - for _, l := range meta.AudioLanguages { - if _, ok := ptbrMap[strings.ToLower(l)]; ok { - hasPortuguese = true - break - } - } - - if hasPortuguese { - uniqueLangs := make(map[string]struct{}) - for _, l := range meta.AudioLanguages { - trimmed := strings.TrimSpace(l) - if trimmed == "" { - continue - } - if code, _, ok := languageCode(trimmed); ok { - uniqueLangs[code] = struct{}{} - } - } - - count := len(uniqueLangs) - if count >= 3 { - audioTag = " MULTI" - } else if count == 2 { - audioTag = " DUAL" - } - } - - if audioTag != "" { - cbrName = cbrAudioMarkerReplacer.Replace(cbrName) - cbrName = strings.Join(strings.Fields(cbrName), " ") - if idx := strings.LastIndex(cbrName, "-"); idx != -1 { - prefix := cbrName[:idx] - suffix := cbrName[idx+1:] - - cleanCustomTag := strings.TrimPrefix(customTag, "-") - if cleanCustomTag != "" && strings.EqualFold(strings.TrimSpace(suffix), cleanCustomTag) { - searchStr := meta.Filename - if searchStr == "" { - searchStr = meta.ReleaseName - } - - if match := audioTagRegex.FindStringSubmatch(searchStr); len(match) > 1 { - originalGroupTag := match[1] - if !strings.EqualFold(originalGroupTag, meta.Release.Group) { - cbrName = prefix + "-" + originalGroupTag + audioTag + "-" + suffix - } else { - cbrName = prefix + audioTag + "-" + suffix - } - } else { - cbrName = prefix + audioTag + "-" + suffix - } - } else { - cbrName = prefix + audioTag + "-" + suffix - } - } else { - cbrName += audioTag - } - } - } - - return addNoGroupSuffix(cbrName, meta, "NoGroup") -} diff --git a/internal/trackers/impl/unit3d/cbr_test.go b/internal/trackers/impl/unit3d/cbr_test.go deleted file mode 100644 index 937f4600..00000000 --- a/internal/trackers/impl/unit3d/cbr_test.go +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "testing" - - "github.com/autobrr/upbrr/pkg/api" -) - -func TestBuildCBRName(t *testing.T) { - tests := []struct { - name string - meta api.PreparedMetadata - customTag string - want string - }{ - { - name: "Basic movie", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL DDP5.1 H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL DDP5.1 H.264-GRP", - }, - { - name: "Portuguese DUAL", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL H.264 DUAL-GRP", - }, - { - name: "Custom tag with original group (same as internal)", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-CBR", - Filename: "Movie.2023.1080p.WEB-DL.H.264-GRP.DUAL.mkv", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "CBR", - }, - customTag: "-CBR", - want: "Movie 2023 1080p WEB-DL H.264 DUAL-CBR", - }, - { - name: "Brazilian PT override", - meta: api.PreparedMetadata{ - ReleaseName: "A Foreign Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{ - Title: "A Foreign Movie", - Year: 2023, - Group: "GRP", - }, - ExternalIDs: api.ExternalIDs{ - Category: "MOVIE", - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - OriginalLanguage: "pt", - RetrievedAKA: "Filme Brasileiro AKA", - }, - }, - Tag: "-GRP", - }, - want: "Filme Brasileiro 2023 1080p WEB-DL H.264-GRP", - }, - { - name: "TV year stripping", - meta: api.PreparedMetadata{ - ReleaseName: "Show Series 2023 S01E01 1080p WEB-DL-GRP", - Release: api.ReleaseInfo{Title: "Show Series", Year: 2023, Group: "GRP"}, - ExternalIDs: api.ExternalIDs{Category: "TV"}, - Tag: "GRP", - }, - want: "Show Series S01E01 1080p WEB-DL-GRP", - }, - { - name: "Anime year stripping", - meta: api.PreparedMetadata{ - ReleaseName: "Anime Movie (2023) 1080p-GRP", - Release: api.ReleaseInfo{Title: "Anime Movie", Year: 2023, Group: "GRP"}, - Anime: true, - Tag: "GRP", - }, - want: "Anime Movie 1080p-GRP", - }, - { - name: "Non-pt AKA removal (with spaces)", - meta: api.PreparedMetadata{ - ReleaseName: "Title AKA Some AKA 2023 1080p-GRP", - Release: api.ReleaseInfo{Title: "Title", Year: 2023, Group: "GRP"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - OriginalLanguage: "en", - RetrievedAKA: "Some AKA", - }, - }, - Tag: "-GRP", - }, - want: "Title AKA 2023 1080p-GRP", - }, - { - name: "Portuguese MULTI", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese", "French"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL H.264 MULTI-GRP", - }, - { - name: "Disc type bypass", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p BluRay REMUX AVC-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - DiscType: "BDMV", - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p BluRay REMUX AVC-GRP", - }, - { - name: "Custom tag with original group from filename", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL-CBR", - Filename: "Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "NEWGRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "CBR", - }, - customTag: "-CBR", - want: "Movie 2023 1080p WEB-DL-ORIGGRP DUAL-CBR", - }, - { - name: "Custom tag and same group from filename", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL-CBR", - Filename: "Movie.2023.1080p.WEB-DL-GRP.DUAL.mkv", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "CBR", - }, - customTag: "-CBR", - want: "Movie 2023 1080p WEB-DL DUAL-CBR", - }, - { - name: "No group suffix", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL-NoGrp", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - }, - want: "Movie 2023 1080p WEB-DL-NoGroup", - }, - { - name: "Blank audio entries with one usable language", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"", "Portuguese", " "}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL H.264-GRP", - }, - { - name: "Blank audio entries with two usable languages", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "", "Portuguese", " "}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL H.264 DUAL-GRP", - }, - { - name: "Blank audio entries with three usable languages", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "", "Portuguese", " ", "French"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL H.264 MULTI-GRP", - }, - { - name: "Custom tag substring in title / source must not inject filename group", - meta: api.PreparedMetadata{ - ReleaseName: "CBR Movie 2023 1080p WEB-DL H.264-GRP", - Filename: "CBR.Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", - Release: api.ReleaseInfo{Title: "CBR Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "GRP", - }, - customTag: "-CBR", - want: "CBR Movie 2023 1080p WEB-DL H.264 DUAL-GRP", - }, - { - name: "Real configured custom suffix should still inject filename group", - meta: api.PreparedMetadata{ - ReleaseName: "Movie 2023 1080p WEB-DL H.264-CBR", - Filename: "Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "CBR", - }, - customTag: "-CBR", - want: "Movie 2023 1080p WEB-DL H.264-ORIGGRP DUAL-CBR", - }, - { - name: "Dubbed inputs with Portuguese replacement eligibility", - meta: api.PreparedMetadata{ - ReleaseName: "Movie Dubbed 2023 1080p WEB-DL-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL DUAL-GRP", - }, - { - name: "Dubbed inputs without Portuguese replacement eligibility", - meta: api.PreparedMetadata{ - ReleaseName: "Movie Dubbed 2023 1080p WEB-DL-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English"}, - Tag: "GRP", - }, - want: "Movie Dubbed 2023 1080p WEB-DL-GRP", - }, - { - name: "Dual-Audio inputs with Portuguese replacement eligibility", - meta: api.PreparedMetadata{ - ReleaseName: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English", "Portuguese"}, - Tag: "GRP", - }, - want: "Movie 2023 1080p WEB-DL DUAL-GRP", - }, - { - name: "Dual-Audio inputs without Portuguese replacement eligibility", - meta: api.PreparedMetadata{ - ReleaseName: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English"}, - Tag: "GRP", - }, - want: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", - }, - { - name: "Existing DD / DDP / AAC / FLAC normalization", - meta: api.PreparedMetadata{ - ReleaseName: "Movie DD+ 5.1 DD 5.1 AAC 2.0 FLAC 2.0-GRP", - Release: api.ReleaseInfo{Title: "Movie", Year: 2023, Group: "GRP"}, - AudioLanguages: []string{"English"}, - Tag: "GRP", - }, - want: "Movie DDP5.1 DD5.1 AAC2.0 FLAC2.0-GRP", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := BuildCBRName(tt.meta, tt.customTag); got != tt.want { - t.Errorf("BuildCBRName() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/internal/trackers/impl/unit3d/definition.go b/internal/trackers/impl/unit3d/definition.go index 65df5a4b..db2d0a89 100644 --- a/internal/trackers/impl/unit3d/definition.go +++ b/internal/trackers/impl/unit3d/definition.go @@ -7,99 +7,264 @@ import ( "context" "errors" "fmt" + "slices" "strings" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" internalerrors "github.com/autobrr/upbrr/internal/errors" "github.com/autobrr/upbrr/internal/redaction" "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/internal/trackers/unit3dmeta" "github.com/autobrr/upbrr/pkg/api" ) +// Definition provides one Unit3D site profile through the shared tracker contracts. type Definition struct { - name string + profile Profile } +// Profile declares Unit3D site identity, endpoint, and site-owned policies. +type Profile struct { + // Name is the stable normalized tracker identifier. + Name string + // BaseURL is the site's default Unit3D endpoint. + BaseURL string + // Site contains optional site-specific payload callbacks. + Site SiteProfile + // Rules contains site-specific release validation requirements. + Rules *trackers.RuleSet + // MetadataPolicy overrides the Unit3D metadata requirements for this site. + MetadataPolicy *trackers.TrackerMetadataPolicy + // AudioPolicy contains site-specific multi-language constraints. + AudioPolicy *trackers.AudioPolicy + // DupePolicy contains site-specific duplicate comparison settings. + DupePolicy *trackers.DupePolicy + // UploadArtifact contains site-specific torrent personalization settings. + UploadArtifact *trackers.UploadArtifactPolicy + // BannedPolicy contains optional dynamic banned-group retrieval settings. + BannedPolicy *trackers.BannedGroupPolicy + // BannedGroups is the site's static banned release-group list. + BannedGroups []string + // ImageHost contains accepted image-host restrictions. + ImageHost *trackers.ImageHostPolicy + // TorrentIdentity contains site-specific torrent identity aliases or overrides. + TorrentIdentity *trackers.TorrentIdentityPolicy + // ClaimPolicy contains generic claim-orchestration settings. + ClaimPolicy *trackers.ClaimPolicy + // DescriptionGroup identifies the site's description override group. + DescriptionGroup string +} + +// New returns a Unit3D definition for the requested registered profile name. func New(name string) *Definition { - return &Definition{name: strings.ToUpper(strings.TrimSpace(name))} + return NewWithProfile(Profile{Name: name}) } -func (d *Definition) Name() string { - return d.name +// NewWithProfile normalizes profile identity, copies static banned groups and +// metadata requirements, and retains the remaining site callbacks and policy +// pointers as immutable compiled configuration. +func NewWithProfile(profile Profile) *Definition { + profile.Name = strings.ToUpper(strings.TrimSpace(profile.Name)) + profile.BaseURL = strings.TrimSpace(profile.BaseURL) + profile.DescriptionGroup = strings.ToLower(strings.TrimSpace(profile.DescriptionGroup)) + profile.BannedGroups = append([]string(nil), profile.BannedGroups...) + profile.MetadataPolicy = cloneMetadataPolicy(profile.MetadataPolicy) + return &Definition{profile: profile} } -func (d *Definition) Upload(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - if unit3dmeta.IsKnown(d.name) { - return uploadUnit3D(ctx, req) +// DescriptionGroup returns the site-specific description override group. +func (d *Definition) DescriptionGroup() string { return d.profile.DescriptionGroup } + +// DefaultBaseURL returns the site's endpoint used when configuration supplies none. +func (d *Definition) DefaultBaseURL() string { return d.profile.BaseURL } + +// ClaimPolicy returns an independent copy of the site's claim policy. +func (d *Definition) ClaimPolicy() *trackers.ClaimPolicy { + if d.profile.ClaimPolicy == nil { + return nil } - select { - case <-ctx.Done(): - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: + policy := *d.profile.ClaimPolicy + return &policy +} + +// UploadArtifactPolicy returns an independent copy of the site's torrent personalization policy. +func (d *Definition) UploadArtifactPolicy() *trackers.UploadArtifactPolicy { + if d.profile.UploadArtifact == nil { + return nil } - if req.Logger != nil { - req.Logger.Infof("trackers: %s upload not implemented (unit3d scaffold)", d.name) + policy := *d.profile.UploadArtifact + return &policy +} + +// MetadataPolicy returns an independent copy of this site's metadata +// requirements. Profiles without an override require TMDB metadata as a strict +// failure for every category. +func (d *Definition) MetadataPolicy() *trackers.TrackerMetadataPolicy { + if d.profile.MetadataPolicy != nil { + return cloneMetadataPolicy(d.profile.MetadataPolicy) + } + return &trackers.TrackerMetadataPolicy{ + Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB}, + Disposition: api.RuleDispositionStrict, + }}, + } +} + +func cloneMetadataPolicy(policy *trackers.TrackerMetadataPolicy) *trackers.TrackerMetadataPolicy { + if policy == nil { + return nil + } + cloned := *policy + cloned.Requirements = slices.Clone(policy.Requirements) + for i := range cloned.Requirements { + cloned.Requirements[i].AnyOf = slices.Clone(cloned.Requirements[i].AnyOf) + } + return &cloned +} + +// Rules returns a shallow copy of the site rules with valid MediaInfo settings +// required for every Unit3D upload. +func (d *Definition) Rules() *trackers.RuleSet { + if d.profile.Rules == nil { + return &trackers.RuleSet{RequireValidMISetting: true} + } + rules := *d.profile.Rules + rules.RequireValidMISetting = true + return &rules +} + +// BannedGroups returns a defensive copy of the site's static release-group blacklist. +func (d *Definition) BannedGroups() []string { return append([]string(nil), d.profile.BannedGroups...) } + +// BannedGroupPolicy returns the site's dynamic banned-group retrieval settings. +func (d *Definition) BannedGroupPolicy() *trackers.BannedGroupPolicy { return d.profile.BannedPolicy } + +// DupePolicy returns the site's duplicate comparison settings. +func (d *Definition) DupePolicy() *trackers.DupePolicy { return d.profile.DupePolicy } + +// AudioPolicy returns the site's multi-language constraints. +func (d *Definition) AudioPolicy() *trackers.AudioPolicy { return d.profile.AudioPolicy } + +// ImageHostPolicy returns the site's accepted image-host restrictions. +func (d *Definition) ImageHostPolicy() *trackers.ImageHostPolicy { return d.profile.ImageHost } + +// TorrentIdentityPolicy returns common Unit3D identity behavior plus site aliases. +func (d *Definition) TorrentIdentityPolicy() *trackers.TorrentIdentityPolicy { + policy := trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{d.profile.BaseURL}, + CommentURLPatterns: []string{d.profile.BaseURL}, + DetailIDPattern: `/(\d+)`, + } + if d.profile.TorrentIdentity != nil { + policy.TrackerURLPatterns = append(policy.TrackerURLPatterns, d.profile.TorrentIdentity.TrackerURLPatterns...) + policy.CommentURLPatterns = append(policy.CommentURLPatterns, d.profile.TorrentIdentity.CommentURLPatterns...) + if d.profile.TorrentIdentity.DetailIDPattern != "" { + policy.DetailIDPattern = d.profile.TorrentIdentity.DetailIDPattern + } + policy.WorkingTrackerID = d.profile.TorrentIdentity.WorkingTrackerID + policy.SearchPreference = d.profile.TorrentIdentity.SearchPreference } - return api.UploadSummary{}, internalerrors.ErrNotImplemented + return &policy +} + +// Name returns the stable tracker identifier for this Unit3D profile. +func (d *Definition) Name() string { + return d.profile.Name } -func (d *Definition) BuildUploadDryRun(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { - if unit3dmeta.IsKnown(d.name) { - return buildUploadDryRunUnit3D(ctx, req) +// TrackerFamily identifies the definition as Unit3D-backed. +func (d *Definition) TrackerFamily() trackers.Family { return trackers.FamilyUnit3D } + +// UploadContentMode declares the aggregate description workflow shared by Unit3D sites. +func (d *Definition) UploadContentMode() trackers.UploadContentMode { + return trackers.UploadContentModeDescription +} + +// AuthCapability declares the API key required by standard Unit3D APIs. +func (d *Definition) AuthCapability() api.TrackerAuthCapability { + return api.TrackerAuthCapability{ + TrackerID: d.profile.Name, + DisplayName: d.profile.Name, + AuthKind: "api_key", + RequiresAPIKey: true, + } +} + +// Prepare builds a fresh intent-scoped tracker plan for this Unit3D profile. +func (d *Definition) Prepare(ctx context.Context, input trackers.PreparationInput) (trackers.TrackerPlan, *trackers.PreparationFailure) { + return trackers.PrepareAdapter(ctx, input, d.prepareDescription, d.prepareUpload) +} + +func (d *Definition) prepareUpload(ctx context.Context, req trackers.PreparationInput) (trackers.PreparedOperation, error) { + if d.profile.BaseURL != "" { + return prepareUnit3DUpload(ctx, req, d.profile.BaseURL, d.profile.Site) } select { case <-ctx.Done(): - return api.TrackerDryRunEntry{}, fmt.Errorf("context canceled: %w", ctx.Err()) + return trackers.PreparedOperation{}, fmt.Errorf("context canceled: %w", ctx.Err()) default: } if req.Logger != nil { - req.Logger.Infof("trackers: dry-run decision=not_implemented tracker=%s", d.name) + req.Logger.Infof("trackers: preparation decision=not_implemented tracker=%s", d.profile.Name) } - return api.TrackerDryRunEntry{}, internalerrors.ErrNotImplemented + return trackers.PreparedOperation{}, internalerrors.ErrNotImplemented } -func (d *Definition) BuildDescription(ctx context.Context, req trackers.DescriptionRequest) (trackers.DescriptionResult, error) { +func (d *Definition) prepareDescription(ctx context.Context, req trackers.PreparationInput) (trackers.DescriptionResult, error) { select { case <-ctx.Done(): return trackers.DescriptionResult{}, fmt.Errorf("context canceled: %w", ctx.Err()) default: } if req.Logger != nil { - req.Logger.Debugf("trackers: %s building unit3d description", d.name) + req.Logger.Debugf("trackers: %s building unit3d description", d.profile.Name) } var err error assets := trackers.DescriptionAssets{} if req.Assets != nil { assets = *req.Assets } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, req.Logger) + assets, err = trackers.PreparedDescriptionAssets(req.Assets) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return trackers.DescriptionResult{}, fmt.Errorf("trackers: %w", err) } if req.Logger != nil { - req.Logger.Warnf("trackers: description assets failed tracker=%s err=%s", d.name, redaction.RedactValue(err.Error(), nil)) + req.Logger.Warnf("trackers: description assets failed tracker=%s err=%s", d.profile.Name, redaction.RedactValue(err.Error(), nil)) } assets = trackers.DescriptionAssets{} } } description := strings.TrimSpace(assets.Description) if !assets.Final { - description, err = buildUnit3DDescription(ctx, d.name, req.Meta, req.AppConfig, req.TrackerConfig, req.Logger, assets.Description, assets.MenuImages, assets.Screenshots) + description, err = buildUnit3DDescription( + ctx, + d.profile.Name, + req.Meta, + req.Runtime.DescriptionConfig(), + req.TrackerConfig, + req.Logger, + assets.Description, + assets.MenuImages, + assets.Screenshots, + d.profile.Site, + ) if err != nil { return trackers.DescriptionResult{}, err } } + if req.Intent == trackers.PreparationIntentDryRun && description != "" { + descriptionunit3d.SaveDescriptionDebug(api.NewDescriptionSubject(req.Meta), "unit3d", req.Runtime.DBPath, description, req.Logger) + } return trackers.DescriptionResult{ Group: "unit3d", Description: description, }, nil } -func DefaultTrackers() []string { - return trackers.Unit3DTrackers() -} - +// Register adds scaffolded Unit3D definitions for the supplied tracker names, +// stops at the first registry error, and treats a nil registry as a no-op. func Register(registry *trackers.Registry, trackersList []string) error { if registry == nil { return nil @@ -111,3 +276,22 @@ func Register(registry *trackers.Registry, trackersList []string) error { } return nil } + +// RegisterProfiles registers composed Unit3D site profiles, rejects an empty +// profile name, stops at the first registry error, and treats a nil registry as +// a no-op. +func RegisterProfiles(registry *trackers.Registry, profiles []Profile) error { + if registry == nil { + return nil + } + for _, profile := range profiles { + if strings.TrimSpace(profile.Name) == "" { + return errors.New("trackers: unit3d profile has empty name") + } + definition := NewWithProfile(profile) + if err := registry.Register(definition); err != nil { + return fmt.Errorf("trackers: %w", err) + } + } + return nil +} diff --git a/internal/trackers/impl/unit3d/description.go b/internal/trackers/impl/unit3d/description.go index 8af57964..7fde9d53 100644 --- a/internal/trackers/impl/unit3d/description.go +++ b/internal/trackers/impl/unit3d/description.go @@ -6,23 +6,43 @@ package unit3d import ( "context" "fmt" - "strings" "github.com/autobrr/upbrr/internal/config" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" "github.com/autobrr/upbrr/pkg/api" ) -func buildUnit3DDescription(ctx context.Context, tracker string, meta api.PreparedMetadata, appConfig config.Config, trackerConfig config.TrackerConfig, logger api.Logger, keptDescription string, menuImages []api.ScreenshotImage, screenshots []api.ScreenshotImage) (string, error) { - if strings.EqualFold(strings.TrimSpace(tracker), "ACM") { - return buildACMDescription(ctx, meta, appConfig, trackerConfig, logger, keptDescription, menuImages, screenshots) +func buildUnit3DDescription( + ctx context.Context, + _ string, + meta api.UploadSubject, + appConfig config.Config, + trackerConfig config.TrackerConfig, + logger api.Logger, + keptDescription string, + menuImages []api.ScreenshotImage, + screenshots []api.ScreenshotImage, + profiles ...SiteProfile, +) (string, error) { + profile := firstSiteProfile(profiles) + if profile.BuildDescription != nil { + return profile.BuildDescription(ctx, meta, appConfig, trackerConfig, logger, keptDescription, menuImages, screenshots) } - description, err := descriptionunit3d.BuildDescription(ctx, meta, appConfig, trackerConfig, logger, keptDescription, menuImages, screenshots) + description, err := descriptionunit3d.BuildDescription( + ctx, + api.NewDescriptionSubject(meta), + appConfig, + trackerConfig, + logger, + keptDescription, + menuImages, + screenshots, + ) if err != nil { return "", fmt.Errorf("trackers: %w", err) } - if strings.EqualFold(strings.TrimSpace(tracker), "SHRI") { - return applySHRIDescriptionNotes(description, meta), nil + if profile.FinalizeDescription != nil { + return profile.FinalizeDescription(description, meta), nil } return description, nil } diff --git a/internal/trackers/impl/unit3d/description_test.go b/internal/trackers/impl/unit3d/description_test.go index eb1cff4e..23e3db3f 100644 --- a/internal/trackers/impl/unit3d/description_test.go +++ b/internal/trackers/impl/unit3d/description_test.go @@ -11,14 +11,14 @@ import ( "testing" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/paths" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/services/db" "github.com/autobrr/upbrr/internal/trackers" "github.com/autobrr/upbrr/pkg/api" ) func TestBuildUnit3DDescriptionTemplate(t *testing.T) { - meta := api.PreparedMetadata{DescriptionTemplate: " Example Template "} + meta := api.UploadSubject{DescriptionTemplate: " Example Template "} cfg := config.Config{} result, err := buildUnit3DDescription(context.Background(), "AITHER", meta, cfg, config.TrackerConfig{}, api.NopLogger{}, "", nil, nil) if err != nil { @@ -33,7 +33,7 @@ func TestBuildUnit3DDescriptionTemplate(t *testing.T) { } func TestBuildUnit3DDescriptionKeptAppendsScreenshots(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} cfg := config.Config{Description: config.DescriptionSettingsConfig{ThumbnailSize: 350}} kept := "Kept Description" screens := []api.ScreenshotImage{{ImgURL: "https://img.example/s1.png"}} @@ -53,7 +53,7 @@ func TestBuildUnit3DDescriptionKeptAppendsScreenshots(t *testing.T) { } func TestBuildUnit3DDescriptionAppliesDescriptionConfig(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} cfg := config.Config{ Description: config.DescriptionSettingsConfig{ ThumbnailSize: 300, @@ -87,9 +87,9 @@ func TestBuildUnit3DDescriptionAppliesDescriptionConfig(t *testing.T) { } func TestBuildUnit3DDescriptionAddsLogoEpisodeOverviewAndMenuImages(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ EpisodeOverview: "Episode [b]overview[/b] text", - ExternalMetadata: api.ExternalMetadata{ + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{Logo: "https://image.tmdb.org/t/p/original/logo.png"}, }, } @@ -121,9 +121,9 @@ func TestBuildUnit3DDescriptionAddsLogoEpisodeOverviewAndMenuImages(t *testing.T } func TestBuildUnit3DDescriptionAddsBlurayLinkAndImages(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ DiscType: "BDMV", - ExternalMetadata: api.ExternalMetadata{ + ProviderMetadata: api.SourceScopedMetadata{ Bluray: &api.BlurayMetadata{ SelectedReleaseID: "123", Candidates: []api.BlurayReleaseCandidate{ @@ -172,9 +172,9 @@ func TestBuildUnit3DDescriptionSanitizesBlurayLink(t *testing.T) { AddBlurayLink: true, }, } - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ DiscType: "BDMV", - ExternalMetadata: api.ExternalMetadata{ + ProviderMetadata: api.SourceScopedMetadata{ Bluray: &api.BlurayMetadata{ SelectedReleaseID: "123", Candidates: []api.BlurayReleaseCandidate{ @@ -198,7 +198,7 @@ func TestBuildUnit3DDescriptionSanitizesBlurayLink(t *testing.T) { t.Fatalf("expected raw BBCode brackets to be escaped, got %q", result) } - meta.ExternalMetadata.Bluray.Candidates[0].URL = "not a url" + meta.ProviderMetadata.Bluray.Candidates[0].URL = "not a url" result, err = buildUnit3DDescription(context.Background(), "AITHER", meta, cfg, config.TrackerConfig{}, api.NopLogger{}, "", nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -209,7 +209,7 @@ func TestBuildUnit3DDescriptionSanitizesBlurayLink(t *testing.T) { } func TestBuildUnit3DDescriptionKeptIncludesScreenshots(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} cfg := config.Config{Description: config.DescriptionSettingsConfig{ThumbnailSize: 350}} kept := "Kept [img]https://img.example/keep.png[/img]" screens := []api.ScreenshotImage{{ImgURL: "https://img.example/s1.png"}} @@ -229,7 +229,7 @@ func TestBuildUnit3DDescriptionSkipsBDInfo(t *testing.T) { MainSettings: config.MainSettingsConfig{DBPath: dbPath}, Description: config.DescriptionSettingsConfig{CustomDescriptionHeader: "Header"}, } - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ SourcePath: filepath.Join(root, "Movie.mkv"), DiscType: "BDMV", SelectedBDMVPlaylists: []api.PlaylistInfo{ @@ -241,7 +241,7 @@ func TestBuildUnit3DDescriptionSkipsBDInfo(t *testing.T) { if err != nil { t.Fatalf("tmp root: %v", err) } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { t.Fatalf("release temp dir: %v", err) } @@ -270,7 +270,7 @@ func TestBuildUnit3DDescriptionSkipsMediaInfo(t *testing.T) { } cfg := config.Config{} - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ SourcePath: filepath.Join(root, "Movie.mkv"), MediaInfoTextPath: miPath, } @@ -285,7 +285,7 @@ func TestBuildUnit3DDescriptionSkipsMediaInfo(t *testing.T) { } func TestBuildUnit3DDescriptionIncludesDVDVOBMediaInfo(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ DiscType: "DVD", DVDVOBMediaInfoText: "VOB_MI_CONTENT", } @@ -303,7 +303,7 @@ func TestBuildUnit3DDescriptionIncludesDVDVOBMediaInfo(t *testing.T) { } func TestBuildUnit3DDescriptionSkipsDVDVOBMediaInfoForNonDVD(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ DiscType: "BDMV", DVDVOBMediaInfoText: "VOB_MI_CONTENT", } @@ -318,7 +318,7 @@ func TestBuildUnit3DDescriptionSkipsDVDVOBMediaInfoForNonDVD(t *testing.T) { } func TestBuildUnit3DDescriptionSkipsDVDVOBMediaInfoWhenEmpty(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ DiscType: "DVD", DVDVOBMediaInfoText: " \n\t", } @@ -333,7 +333,7 @@ func TestBuildUnit3DDescriptionSkipsDVDVOBMediaInfoWhenEmpty(t *testing.T) { } func TestBuildUnit3DDescriptionIncludesTonemapHeaderForHDRScreens(t *testing.T) { - meta := api.PreparedMetadata{HDR: "HDR10"} + meta := api.UploadSubject{HDR: "HDR10"} cfg := config.Config{ ScreenshotHandling: config.ScreenshotHandlingConfig{ToneMap: true}, Description: config.DescriptionSettingsConfig{ @@ -352,7 +352,7 @@ func TestBuildUnit3DDescriptionIncludesTonemapHeaderForHDRScreens(t *testing.T) } func TestBuildUnit3DDescriptionSkipsTonemapHeaderForNonHDR(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} cfg := config.Config{ ScreenshotHandling: config.ScreenshotHandlingConfig{ToneMap: true}, Description: config.DescriptionSettingsConfig{ @@ -370,31 +370,8 @@ func TestBuildUnit3DDescriptionSkipsTonemapHeaderForNonHDR(t *testing.T) { } } -func TestBuildUnit3DDescriptionACMTransformsBaseDescription(t *testing.T) { - meta := api.PreparedMetadata{ - Type: "WEBDL", - ServiceLongName: "Netflix", - } - result, err := buildUnit3DDescription(context.Background(), "ACM", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, "[pre]x[/pre]\n[hide=test]y[/hide]\n[img]https://img.example/z.png[/img]", nil, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "[code]x[/code]") { - t.Fatalf("expected pre converted to code, got %q", result) - } - if !strings.Contains(result, "[spoiler=test]y[/spoiler]") { - t.Fatalf("expected hide converted to spoiler, got %q", result) - } - if !strings.Contains(result, "not transcoded, just remuxed from the direct Netflix stream") { - t.Fatalf("expected ACM web source header, got %q", result) - } - if !strings.Contains(result, "[img=300]https://img.example/z.png[/img]") { - t.Fatalf("expected img resize normalization, got %q", result) - } -} - func TestBuildUnit3DDescriptionFinalizesUnit3DBBCode(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} kept := "[hide=Extras]notes[/hide]\n[user]name[/user]\n[comparison=Source, Encode]https://img.example/a.png https://img.example/b.png[/comparison]" result, err := buildUnit3DDescription(context.Background(), "AITHER", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) @@ -415,37 +392,6 @@ func TestBuildUnit3DDescriptionFinalizesUnit3DBBCode(t *testing.T) { } } -func TestBuildUnit3DDescriptionAddsSHRIIslandReleaseNotes(t *testing.T) { - meta := api.PreparedMetadata{ - Release: api.ReleaseInfo{Group: "island"}, - } - - result, err := buildUnit3DDescription(context.Background(), "SHRI", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, "Base description", nil, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(result, "Release Shareisland 🏴‍☠️") { - t.Fatalf("expected Shareisland notes, got %q", result) - } - if !strings.Contains(result, "Base description") { - t.Fatalf("expected base description to be preserved, got %q", result) - } -} - -func TestBuildUnit3DDescriptionSkipsSHRIIslandReleaseNotesForOtherGroups(t *testing.T) { - meta := api.PreparedMetadata{ - Release: api.ReleaseInfo{Group: "other"}, - } - - result, err := buildUnit3DDescription(context.Background(), "SHRI", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, "Base description", nil, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if strings.Contains(result, "Release Shareisland") { - t.Fatalf("did not expect Shareisland notes, got %q", result) - } -} - func TestBuildUnit3DDescriptionSkipsDuplicateTemplateAndKeptContent(t *testing.T) { block := `[center] [url=https://pixhost.to/8ca234.png][img=350]https://pixhost.to/8ca234.png[/img][/url] @@ -453,7 +399,7 @@ func TestBuildUnit3DDescriptionSkipsDuplicateTemplateAndKeptContent(t *testing.T [/center] [right][url=https://github.com/autobrr/upbrr][size=10]upbrr[/size][/url][/right]` - meta := api.PreparedMetadata{DescriptionTemplate: block} + meta := api.UploadSubject{DescriptionTemplate: block} result, err := buildUnit3DDescription(context.Background(), "AITHER", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, block, nil, nil) if err != nil { @@ -479,7 +425,7 @@ func TestBuildUnit3DDescriptionStripsKnownBotSignatures(t *testing.T) { "[right]Created by Upload Assistant[/right]", }, "\n") - result, err := buildUnit3DDescription(context.Background(), "AITHER", api.PreparedMetadata{}, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) + result, err := buildUnit3DDescription(context.Background(), "AITHER", api.UploadSubject{}, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -500,7 +446,7 @@ func TestBuildUnit3DDescriptionKeepsCenteredScreenshotsBeforeAitherFooter(t *tes "[center][b][size=20]brush[/size][/b] This is an internal release which was first released exclusively on Aither. Cheers to all the Aither users[/center]", }, "\n\n") - result, err := buildUnit3DDescription(context.Background(), "AITHER", api.PreparedMetadata{}, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) + result, err := buildUnit3DDescription(context.Background(), "AITHER", api.UploadSubject{}, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -532,7 +478,7 @@ func TestBuildUnit3DDescriptionReplacesExistingScreenshotBlock(t *testing.T) { {RawURL: "https://new.example/4.png", WebURL: "https://web.example/4"}, } - result, err := buildUnit3DDescription(context.Background(), "AITHER", api.PreparedMetadata{}, config.Config{ + result, err := buildUnit3DDescription(context.Background(), "AITHER", api.UploadSubject{}, config.Config{ Description: config.DescriptionSettingsConfig{ThumbnailSize: 350, ScreensPerRow: "2"}, }, config.TrackerConfig{}, api.NopLogger{}, base, nil, screens) if err != nil { @@ -556,7 +502,7 @@ func TestBuildUnit3DDescriptionStripsExistingSceneNFOBlock(t *testing.T) { kept := `[center][spoiler=Scene NFO:][code]stale scene nfo[/code][/spoiler][/center] Custom body` - meta := api.PreparedMetadata{Scene: true} + meta := api.UploadSubject{Scene: true} result, err := buildUnit3DDescription(context.Background(), "AITHER", meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, kept, nil, nil) if err != nil { @@ -575,12 +521,12 @@ Custom body` func TestDefinitionBuildDescriptionFormatsOverrideContent(t *testing.T) { definition := New("AITHER") - result, err := definition.BuildDescription(context.Background(), trackers.DescriptionRequest{ + result, err := definition.prepareDescription(context.Background(), trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{}, - AppConfig: config.Config{ + Meta: api.UploadSubject{}, + Runtime: trackers.PreparationRuntimeFromConfig(config.Config{ Description: config.DescriptionSettingsConfig{ThumbnailSize: 350}, - }, + }), Logger: api.NopLogger{}, Assets: &trackers.DescriptionAssets{ Description: "[align=center][img width=350]https://img.example/a.png[/img][/align]", @@ -599,7 +545,7 @@ func TestDefinitionBuildDescriptionFormatsOverrideContent(t *testing.T) { } func TestBuildUnit3DDescriptionFiltersMenuImagesFromScreenshots(t *testing.T) { - meta := api.PreparedMetadata{} + meta := api.UploadSubject{} cfg := config.Config{} menuImages := []api.ScreenshotImage{{ImgURL: "https://img.example/menu1.png"}} screenshots := []api.ScreenshotImage{ diff --git a/internal/trackers/impl/unit3d/doc.go b/internal/trackers/impl/unit3d/doc.go index 0805b529..451a24fd 100644 --- a/internal/trackers/impl/unit3d/doc.go +++ b/internal/trackers/impl/unit3d/doc.go @@ -10,52 +10,46 @@ // // # Architecture // -// The implementation is split into: -// - definition.go: Tracker registry and routing to specific implementations -// - upload.go: Core Unit3D upload logic (multipart form, API calls) -// - _name.go: Tracker-specific name formatting rules +// Shared protocol behavior lives in this package. Site-owned profiles, rules, +// banned groups, and payload hooks live under sites/. // -// # Adding a New Unit3D Tracker +// # Adding a Standard Unit3D Tracker // // To add support for a new Unit3D tracker (e.g., "EXAMPLE"): // -// 1. Add the tracker to the known list in definition.go: +// 1. Create sites/example/profile.go. Profile owns the tracker identity, +// endpoint, and composed site policies: // -// knownUnit3DTrackers = []string{"AITHER", "BLU", "LST", "LUME", "EXAMPLE"} -// -// 2. Create a name formatter in example_name.go: -// -// func buildExampleName(meta api.PreparedMetadata) string { -// name := meta.ReleaseName -// // Apply EXAMPLE-specific name formatting rules -// return name +// func Profile() unit3d.Profile { +// return unit3d.Profile{ +// Name: "EXAMPLE", +// BaseURL: "https://example.invalid", +// Rules: Rules(), +// } // } // -// 3. Add the name formatter to the routing in definition.go Upload(): -// -// case "EXAMPLE": -// name = buildExampleName(meta) -// -// 4. Add configuration in config.yaml: +// 2. Create sites/example/rules.go for release eligibility and typed policies +// such as language and audio handling. Optional site-owned behavior stays beside +// these files, for example banned_groups.go, auth.go, or custom payload hooks. // -// EXAMPLE: -// api_key: "your-api-key" -// announce_url: "https://example.tracker/announce/PASSKEY" -// anon: false -// modq: false +// 3. Add one import and one Profile() entry to unit3DDefinitions in +// internal/trackers/impl/registry.go. // -// 5. Register in impl/registry.go if not auto-registered: +// 4. Add one credential/config stanza to internal/config/defaults/example.yaml. +// Do not add a tracker URL; Profile.BaseURL is authoritative. // -// registry.Register(unit3d.New()) +// 5. Add cross-tracker rule cases to internal/trackers/rules_test.go. Add +// focused site tests only for behavior that differs from shared Unit3D handling. // -// That's it! The core upload logic in upload.go is tracker-agnostic and will -// handle the API interaction, torrent/NFO attachments, and response parsing. +// No generic metadata, auth, image-hosting, torrent-client, or frontend +// tracker-name edit is required. Unsupported saved entries remain inert; the +// runtime does not infer or register custom Unit3D trackers. // // # Testing // -// Unit tests live in upload_test.go and cover: +// Shared unit tests live in upload_test.go and cover: // - Category/type/resolution mapping -// - Name formatting per tracker +// - Site profile callbacks // - Form payload construction // - Response parsing // diff --git a/internal/trackers/impl/unit3d/dupe.go b/internal/trackers/impl/unit3d/dupe.go new file mode 100644 index 00000000..d611964b --- /dev/null +++ b/internal/trackers/impl/unit3d/dupe.go @@ -0,0 +1,52 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "context" + "strings" + + "github.com/autobrr/upbrr/internal/config" + trackerdata "github.com/autobrr/upbrr/internal/trackers/data" + "github.com/autobrr/upbrr/internal/trackers/dupe" + "github.com/autobrr/upbrr/pkg/api" +) + +type dupeSearcher struct { + trackerID string + cfg config.Config + client *trackerdata.Client +} + +// NewDuplicateAdapter returns a duplicate-search adapter bound to one immutable dependency set. +func (d *Definition) NewDuplicateAdapter(deps dupe.Dependencies) dupe.Adapter { + cfg := deps.BoundConfig() + httpClient := deps.HTTPClient() + logger := deps.Logger() + _ = logger + return &dupeSearcher{ + trackerID: deps.Tracker(), + cfg: cfg, + client: trackerdata.NewClientWithRegistry(cfg, logger, httpClient, deps.Registry()), + } +} + +func (s *dupeSearcher) Search(ctx context.Context, meta api.DuplicateSubject) dupe.AdapterResult { + tracker := s.trackerID + if strings.TrimSpace(trackerdata.TrackerAPIKey(s.cfg, tracker)) == "" { + return dupe.NotRun(dupe.NotRunMissingCredentials, "missing api_key for tracker", nil) + } + params := buildDupeSearchParams(meta, tracker) + if len(params) == 0 { + return dupe.NotRun(dupe.NotRunMissingMetadata, "missing required metadata for dupe search", nil) + } + entries, warning, err := s.client.SearchTorrents(ctx, tracker, params, strings.TrimSpace(meta.DiscType) != "") + if err != nil { + return dupe.Failed(dupe.FailureRequest, "duplicate search failed", err) + } + if warning != "" { + return dupe.Resolved(entries, []string{warning}) + } + return dupe.Resolved(entries, nil) +} diff --git a/internal/trackers/impl/unit3d/dupe_params.go b/internal/trackers/impl/unit3d/dupe_params.go new file mode 100644 index 00000000..d8369ded --- /dev/null +++ b/internal/trackers/impl/unit3d/dupe_params.go @@ -0,0 +1,159 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "net/url" + "regexp" + "strconv" + "strings" + + trackerdata "github.com/autobrr/upbrr/internal/trackers/data" + "github.com/autobrr/upbrr/pkg/api" +) + +var dupeSeasonPattern = regexp.MustCompile(`(?i)\bS(\d{1,2})`) + +func buildDupeSearchParams(meta api.DuplicateSubject, tracker string) url.Values { + tmdbID := meta.Identity.TMDBID + if tmdbID == 0 { + return nil + } + + categoryValue, err := meta.Identity.RequireCategory() + if err != nil { + return nil + } + category := strings.ToUpper(string(categoryValue)) + categoryID := resolveUnit3DDupeCategoryID(tracker, category) + if categoryID == "" { + return nil + } + + typeValue := strings.ToUpper(strings.TrimSpace(meta.Type)) + if typeValue == "" { + typeValue = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) + } + if typeValue == "" && strings.TrimSpace(meta.DiscType) != "" { + typeValue = "DISC" + } + if typeValue == "" { + if meta.ReleaseNameOverrides.Type != nil { + typeValue = strings.ToUpper(strings.TrimSpace(*meta.ReleaseNameOverrides.Type)) + } + } + typeID := resolveUnit3DDupeTypeID(tracker, typeValue) + + resolution := strings.TrimSpace(meta.Release.Resolution) + if resolution == "" { + if meta.ReleaseNameOverrides.Resolution != nil { + resolution = strings.TrimSpace(*meta.ReleaseNameOverrides.Resolution) + } + } + resolutionID := resolveUnit3DDupeResolutionID(tracker, resolution) + if resolutionID == "" { + resolutionID = resolveUnit3DDupeResolutionID(tracker, "8640p") + } + + params := url.Values{} + params.Set("tmdbId", strconv.Itoa(tmdbID)) + params.Set("categories[]", categoryID) + params.Set("name", "") + params.Set("perPage", "100") + + if !strings.EqualFold(tracker, "OTW") { + if resolutionID == "3" || resolutionID == "4" { + params.Add("resolutions[]", "3") + params.Add("resolutions[]", "4") + } else { + params.Set("resolutions[]", resolutionID) + } + } + + if typeID != "" && !strings.EqualFold(tracker, "SP") && !strings.EqualFold(tracker, "STC") { + params.Set("types[]", typeID) + } + + if strings.EqualFold(category, "TV") { + season := resolveSeasonValue(meta) + if season != "" { + params.Set("name", " "+season) + } + } + + return params +} + +func resolveUnit3DDupeCategoryID(tracker string, category string) string { + if strings.EqualFold(tracker, "EMUW") { + switch strings.ToUpper(strings.TrimSpace(category)) { + case "MOVIE", "FANRES": + return "1" + case "TV": + return "2" + default: + return "" + } + } + return trackerdata.CategoryID(category) +} + +func resolveUnit3DDupeTypeID(tracker string, typeValue string) string { + if strings.EqualFold(tracker, "EMUW") { + mapping := map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "SD": "7", + } + return mapping[strings.ToUpper(strings.TrimSpace(typeValue))] + } + return trackerdata.TypeID(typeValue) +} + +func resolveUnit3DDupeResolutionID(tracker string, resolution string) string { + if strings.EqualFold(tracker, "EMUW") { + mapping := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + "720p": "5", + "576p": "6", + "540p": "7", + "480p": "8", + "8640p": "10", + } + return mapping[strings.ToLower(strings.TrimSpace(resolution))] + } + return trackerdata.ResolutionID(resolution) +} + +func resolveSeasonValue(meta api.DuplicateSubject) string { + if meta.ReleaseNameOverrides.Season != nil { + return normalizeSeasonEpisode(*meta.ReleaseNameOverrides.Season) + } + if match := dupeSeasonPattern.FindStringSubmatch(meta.ReleaseName); len(match) == 2 { + return "S" + match[1] + } + return "" +} + +func normalizeSeasonEpisode(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + upper := strings.ToUpper(trimmed) + if strings.HasPrefix(upper, "S") { + return upper + } + if num, err := strconv.Atoi(trimmed); err == nil { + return "S" + strconv.Itoa(num) + } + return upper +} diff --git a/internal/trackers/impl/unit3d/dupe_params_test.go b/internal/trackers/impl/unit3d/dupe_params_test.go new file mode 100644 index 00000000..531f6cbc --- /dev/null +++ b/internal/trackers/impl/unit3d/dupe_params_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildUnit3DSearchParamsSkipsResolutionForOTW(t *testing.T) { + t.Parallel() + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{TMDBID: 123, Category: "TV"}, + Type: "WEBDL", + Release: api.ReleaseInfo{Resolution: "1080p"}, + ReleaseName: "Show.S01E02.1080p.WEB-DL.H264-GRP", + } + + params := buildDupeSearchParams(meta, "OTW") + if _, ok := params["resolutions[]"]; ok { + t.Fatalf("did not expect OTW resolution filter, got %#v", params["resolutions[]"]) + } + if got := params.Get("name"); got != " S01" { + t.Fatalf("expected season search name, got %q", got) + } +} + +func TestBuildUnit3DSearchParamsKeepsResolutionForOtherTrackers(t *testing.T) { + t.Parallel() + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{TMDBID: 123, Category: "TV"}, + Type: "WEBDL", + Release: api.ReleaseInfo{Resolution: "1080p"}, + ReleaseName: "Show.S01E02.1080p.WEB-DL.H264-GRP", + } + + params := buildDupeSearchParams(meta, "AITHER") + if got := params["resolutions[]"]; len(got) != 2 || got[0] != "3" || got[1] != "4" { + t.Fatalf("expected 1080p/1080i search filters, got %#v", got) + } +} + +func TestBuildUnit3DSearchParamsUsesEMUWTrackerMappings(t *testing.T) { + t.Parallel() + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{TMDBID: 123, Category: "MOVIE"}, + Type: "WEBDL", + Release: api.ReleaseInfo{Resolution: "540p"}, + ReleaseName: "Movie.2025.540p.WEB-DL.H264-GRP", + } + + params := buildDupeSearchParams(meta, "EMUW") + if got := params.Get("name"); got != "" { + t.Fatalf("expected empty movie search name, got %q", got) + } + if got := params.Get("categories[]"); got != "1" { + t.Fatalf("expected EMUW movie category 1, got %q", got) + } + if got := params.Get("types[]"); got != "4" { + t.Fatalf("expected EMUW WEBDL type 4, got %q", got) + } + if got := params.Get("resolutions[]"); got != "7" { + t.Fatalf("expected EMUW 540p resolution 7, got %q", got) + } + if got := params.Get("perPage"); got != "100" { + t.Fatalf("expected perPage=100, got %q", got) + } +} + +func TestBuildUnit3DSearchParamsUsesEMUWPaired1080Resolution(t *testing.T) { + t.Parallel() + + meta := api.DuplicateSubject{ + Identity: api.ExternalIdentity{TMDBID: 123, Category: "TV"}, + Type: "SD", + Release: api.ReleaseInfo{Resolution: "1080i"}, + ReleaseName: "Show.S02E03.1080i.HDTV.H264-GRP", + } + + params := buildDupeSearchParams(meta, "EMUW") + if got := params.Get("name"); got != " S02" { + t.Fatalf("expected season search name, got %q", got) + } + if got := params.Get("categories[]"); got != "2" { + t.Fatalf("expected EMUW TV category 2, got %q", got) + } + if got := params.Get("types[]"); got != "7" { + t.Fatalf("expected EMUW SD type 7, got %q", got) + } + if got := params["resolutions[]"]; len(got) != 2 || got[0] != "3" || got[1] != "4" { + t.Fatalf("expected EMUW 1080p/1080i filters, got %#v", got) + } +} diff --git a/internal/trackers/impl/unit3d/emuw.go b/internal/trackers/impl/unit3d/emuw.go deleted file mode 100644 index ae880bf3..00000000 --- a/internal/trackers/impl/unit3d/emuw.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteEMUWProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DEMUWTypeID, - resolveResolutionID: resolveUnit3DEMUWResolutionID, - } -} - -func resolveUnit3DEMUWTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "ENCODE": "3", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - } - typeID := mapping[inferUnit3DType(meta)] - if typeID == "" { - return "3" - } - return typeID -} - -func resolveUnit3DEMUWResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1080p": "3", - "1080i": "4", - "720p": "5", - "576p": "6", - "540p": "7", - "480p": "8", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/filter_doc.go b/internal/trackers/impl/unit3d/filter_doc.go new file mode 100644 index 00000000..de0d1b4c --- /dev/null +++ b/internal/trackers/impl/unit3d/filter_doc.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +// Unit3D filter contract +// +// The upstream torrent filter endpoint is GET /api/torrents/filter. It returns +// a paginated torrent index and accepts optional query parameters in these +// groups: +// +// - Pagination and ordering: perPage, sortField, and sortDirection. +// - Text matching: name, description, mediainfo, bdinfo, uploader, keywords, +// and file_name. +// - Release dates and taxonomy: startYear, endYear, categories[], types[], +// resolutions[], and genres[]. +// - External IDs: tmdbId, imdbId, tvdbId, and malId. +// - Collection membership: playlistId and collectionId. +// - Promotion and provenance: free, doubleup, featured, refundable, +// highspeed, internal, and personalRelease. +// - Swarm state: alive, dying, and dead. +// - TV coordinates: seasonNumber and episodeNumber. +// +// upbrr builds only the subset needed for duplicate detection in dupe_params.go, +// encodes parameters with net/url.Values, and normalizes returned torrents in +// the shared tracker-data client. Some deployments expose additional endpoints, +// such as /api/torrents/pending; those are site compatibility extensions and not +// part of the upstream filter contract. +// +// Source: https://github.com/HDInnovations/UNIT3D/blob/master/book/src/torrent_api.md diff --git a/internal/trackers/impl/unit3d/ihd.go b/internal/trackers/impl/unit3d/ihd.go deleted file mode 100644 index 4396b5b3..00000000 --- a/internal/trackers/impl/unit3d/ihd.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteIHDProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveResolutionID: resolveUnit3DIHDResolutionID, - resolveCategoryID: resolveUnit3DIHDCategoryID, - } -} - -func resolveUnit3DIHDCategoryID(meta api.PreparedMetadata) string { - category := resolveUnit3DCategory(meta) - if strings.EqualFold(category, "TV") && meta.Anime { - return "3" - } - if strings.EqualFold(category, "MOVIE") && meta.Anime { - return "4" - } - return resolveUnit3DCategoryID(meta) -} - -func resolveUnit3DIHDResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1440p": "3", - "1080p": "3", - "1080i": "4", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/itt.go b/internal/trackers/impl/unit3d/itt.go deleted file mode 100644 index f1e9130f..00000000 --- a/internal/trackers/impl/unit3d/itt.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteITTProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DITTTypeID} -} - -func resolveUnit3DITTTypeID(meta api.PreparedMetadata) string { - name := strings.ToUpper(strings.TrimSpace(meta.ReleaseName)) - switch { - case strings.Contains(name, "DLMUX"): - return "27" - case strings.Contains(name, "BDMUX"): - return "29" - case strings.Contains(name, "WEBMUX"): - return "26" - case strings.Contains(name, "DVDMUX"): - return "39" - case strings.Contains(name, "BDRIP"): - return "25" - } - - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - "ENCODE": "3", - "DVDRIP": "24", - "CINEMA-MD": "14", - } - return mapping[inferUnit3DType(meta)] -} diff --git a/internal/trackers/impl/unit3d/ldu.go b/internal/trackers/impl/unit3d/ldu.go deleted file mode 100644 index c2ee3f9d..00000000 --- a/internal/trackers/impl/unit3d/ldu.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteLDUProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveCategoryID: resolveUnit3DLDUCategoryID} -} - -func resolveUnit3DLDUCategoryID(meta api.PreparedMetadata) string { - category := resolveUnit3DCategory(meta) - genres := strings.ToLower(strings.TrimSpace(strings.Join([]string{ - strings.TrimSpace(meta.Release.Genre), - resolveKeywords(meta), - resolveTMDBGenres(meta), - resolveIMDBGenres(meta), - }, ","))) - hasEnglishAudio := hasEnglishLanguage(meta.AudioLanguages) - hasEnglishSubs := hasEnglishLanguage(meta.SubtitleLanguages) - containsDubbed := strings.Contains(strings.ToLower(strings.TrimSpace(meta.Audio)), "dubbed") - edition := strings.ToLower(strings.TrimSpace(meta.Edition)) - - if strings.EqualFold(category, "MOVIE") { - if meta.Anime || meta.ExternalIDs.MALID != 0 { - return "8" - } - if strings.Contains(edition, "fanedit") || strings.Contains(edition, "fanres") { - return "12" - } - if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { - return "21" - } - if hasAdultToken(genres) { - if !hasEnglishAudio && !hasEnglishSubs { - return "45" - } - return "6" - } - if strings.Contains(genres, "documentary") { - return "17" - } - if strings.Contains(genres, "musical") { - return "25" - } - if !hasEnglishAudio && !hasEnglishSubs { - return "22" - } - if containsDubbed { - return "27" - } - return "1" - } - - if strings.EqualFold(category, "TV") { - if meta.Anime || meta.ExternalIDs.MALID != 0 { - return "9" - } - if strings.Contains(genres, "documentary") { - return "40" - } - if !hasEnglishAudio && !hasEnglishSubs { - return "29" - } - if meta.TVPack { - return "2" - } - if containsDubbed { - return "31" - } - return "41" - } - - return resolveUnit3DCategoryID(meta) -} diff --git a/internal/trackers/impl/unit3d/localized_name.go b/internal/trackers/impl/unit3d/localized_name.go new file mode 100644 index 00000000..7ae512a5 --- /dev/null +++ b/internal/trackers/impl/unit3d/localized_name.go @@ -0,0 +1,116 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "regexp" + "strconv" + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +var localizedCodecReplacer = strings.NewReplacer("DD+ ", "DDP", "DD ", "DD", "AAC ", "AAC", "FLAC ", "FLAC") +var localizedAudioMarkerReplacer = strings.NewReplacer("Dubbed", "", "Dual-Audio", "") +var localizedAudioTagRegex = regexp.MustCompile(`(?i)-([^.-]+)\.(?:DUAL|MULTI)`) +var portugueseLanguageNames = map[string]struct{}{ + "português": {}, + "portuguese": {}, + "pt-br": {}, + "pt": {}, +} + +// FormatLocalizedName applies the shared Portuguese-localized naming convention +// used by Unit3D sites that opt into it. +func FormatLocalizedName(meta api.UploadSubject, customTag string) string { + name := baseReleaseName(meta) + if name == "" { + return "" + } + name = localizedCodecReplacer.Replace(name) + category := resolveUnit3DCategory(meta) + if (category == "TV" || meta.Anime) && meta.Release.Year > 0 { + year := strconv.Itoa(meta.Release.Year) + if strings.Contains(name, year) { + name = strings.ReplaceAll(name, "("+year+")", "") + name = strings.ReplaceAll(name, year, "") + name = strings.TrimSpace(name) + } + } + originalLanguage := strings.ToLower(resolveOriginalLanguage(meta)) + aka := "" + if meta.ProviderMetadata.TMDB != nil { + aka = meta.ProviderMetadata.TMDB.RetrievedAKA + } + _, portuguese := portugueseLanguageNames[originalLanguage] + if !portuguese && aka != "" { + name = strings.ReplaceAll(name, aka, "") + name = strings.Join(strings.Fields(name), " ") + } + if portuguese && aka != "" { + cleanAKA := strings.TrimSpace(strings.ReplaceAll(aka, "AKA", "")) + title := meta.Release.Title + name = strings.ReplaceAll(name, aka, "") + name = strings.ReplaceAll(name, strings.ReplaceAll(aka, " ", "."), "") + name = strings.ReplaceAll(name, title, cleanAKA) + name = strings.ReplaceAll(name, strings.ReplaceAll(title, " ", "."), cleanAKA) + name = strings.Join(strings.Fields(name), " ") + } + formatted := name + if !isDiscType(meta.DiscType) { + audioTag, hasPortuguese := "", false + for _, language := range meta.AudioLanguages { + if _, ok := portugueseLanguageNames[strings.ToLower(language)]; ok { + hasPortuguese = true + break + } + } + if hasPortuguese { + languages := make(map[string]struct{}) + for _, language := range meta.AudioLanguages { + trimmed := strings.TrimSpace(language) + if trimmed == "" { + continue + } + if code, _, ok := languageCode(trimmed); ok { + languages[code] = struct{}{} + } + } + if len(languages) >= 3 { + audioTag = " MULTI" + } else if len(languages) == 2 { + audioTag = " DUAL" + } + } + if audioTag != "" { + formatted = localizedAudioMarkerReplacer.Replace(formatted) + formatted = strings.Join(strings.Fields(formatted), " ") + if index := strings.LastIndex(formatted, "-"); index != -1 { + prefix, suffix := formatted[:index], formatted[index+1:] + cleanTag := strings.TrimPrefix(customTag, "-") + if cleanTag != "" && strings.EqualFold(strings.TrimSpace(suffix), cleanTag) { + search := meta.Filename + if search == "" { + search = meta.ReleaseName + } + if match := localizedAudioTagRegex.FindStringSubmatch(search); len(match) > 1 { + group := match[1] + if !strings.EqualFold(group, meta.Release.Group) { + formatted = prefix + "-" + group + audioTag + "-" + suffix + } else { + formatted = prefix + audioTag + "-" + suffix + } + } else { + formatted = prefix + audioTag + "-" + suffix + } + } else { + formatted = prefix + audioTag + "-" + suffix + } + } else { + formatted += audioTag + } + } + } + return addNoGroupSuffix(formatted, meta, "NoGroup") +} diff --git a/internal/trackers/impl/unit3d/localized_name_test.go b/internal/trackers/impl/unit3d/localized_name_test.go new file mode 100644 index 00000000..236dd44f --- /dev/null +++ b/internal/trackers/impl/unit3d/localized_name_test.go @@ -0,0 +1,357 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestFormatLocalizedNamePortugueseConvention(t *testing.T) { + tests := []struct { + name string + meta api.UploadSubject + customTag string + want string + }{ + { + name: "Basic movie", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL DDP5.1 H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL DDP5.1 H.264-GRP", + }, + { + name: "Portuguese DUAL", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL H.264 DUAL-GRP", + }, + { + name: "Custom tag with original group (same as internal)", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-CBR", + Filename: "Movie.2023.1080p.WEB-DL.H.264-GRP.DUAL.mkv", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "CBR", + }, + customTag: "-CBR", + want: "Movie 2023 1080p WEB-DL H.264 DUAL-CBR", + }, + { + name: "Brazilian PT override", + meta: api.UploadSubject{ + ReleaseName: "A Foreign Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "A Foreign Movie", + Year: 2023, + Group: "GRP", + }, + Identity: api.ExternalIdentity{ + Category: "MOVIE", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + OriginalLanguage: "pt", + RetrievedAKA: "Filme Brasileiro AKA", + }, + }, + Tag: "-GRP", + }, + want: "Filme Brasileiro 2023 1080p WEB-DL H.264-GRP", + }, + { + name: "TV year stripping", + meta: api.UploadSubject{ + ReleaseName: "Show Series 2023 S01E01 1080p WEB-DL-GRP", + Release: api.ReleaseInfo{ + Title: "Show Series", + Year: 2023, + Group: "GRP", + }, + Identity: api.ExternalIdentity{Category: "TV"}, + Tag: "GRP", + }, + want: "Show Series S01E01 1080p WEB-DL-GRP", + }, + { + name: "Anime year stripping", + meta: api.UploadSubject{ + ReleaseName: "Anime Movie (2023) 1080p-GRP", + Release: api.ReleaseInfo{ + Title: "Anime Movie", + Year: 2023, + Group: "GRP", + }, + Anime: true, + Tag: "GRP", + }, + want: "Anime Movie 1080p-GRP", + }, + { + name: "Non-pt AKA removal (with spaces)", + meta: api.UploadSubject{ + ReleaseName: "Title AKA Some AKA 2023 1080p-GRP", + Release: api.ReleaseInfo{ + Title: "Title", + Year: 2023, + Group: "GRP", + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + OriginalLanguage: "en", + RetrievedAKA: "Some AKA", + }, + }, + Tag: "-GRP", + }, + want: "Title AKA 2023 1080p-GRP", + }, + { + name: "Portuguese MULTI", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese", "French"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL H.264 MULTI-GRP", + }, + { + name: "Disc type bypass", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p BluRay REMUX AVC-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + DiscType: "BDMV", + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p BluRay REMUX AVC-GRP", + }, + { + name: "Custom tag with original group from filename", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL-CBR", + Filename: "Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "NEWGRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "CBR", + }, + customTag: "-CBR", + want: "Movie 2023 1080p WEB-DL-ORIGGRP DUAL-CBR", + }, + { + name: "Custom tag and same group from filename", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL-CBR", + Filename: "Movie.2023.1080p.WEB-DL-GRP.DUAL.mkv", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "CBR", + }, + customTag: "-CBR", + want: "Movie 2023 1080p WEB-DL DUAL-CBR", + }, + { + name: "No group suffix", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL-NoGrp", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + }, + want: "Movie 2023 1080p WEB-DL-NoGroup", + }, + { + name: "Blank audio entries with one usable language", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"", "Portuguese", " "}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL H.264-GRP", + }, + { + name: "Blank audio entries with two usable languages", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "", "Portuguese", " "}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL H.264 DUAL-GRP", + }, + { + name: "Blank audio entries with three usable languages", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "", "Portuguese", " ", "French"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL H.264 MULTI-GRP", + }, + { + name: "Custom tag substring in title / source must not inject filename group", + meta: api.UploadSubject{ + ReleaseName: "CBR Movie 2023 1080p WEB-DL H.264-GRP", + Filename: "CBR.Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", + Release: api.ReleaseInfo{ + Title: "CBR Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "GRP", + }, + customTag: "-CBR", + want: "CBR Movie 2023 1080p WEB-DL H.264 DUAL-GRP", + }, + { + name: "Real configured custom suffix should still inject filename group", + meta: api.UploadSubject{ + ReleaseName: "Movie 2023 1080p WEB-DL H.264-CBR", + Filename: "Movie.2023.1080p.WEB-DL-ORIGGRP.DUAL.mkv", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "CBR", + }, + customTag: "-CBR", + want: "Movie 2023 1080p WEB-DL H.264-ORIGGRP DUAL-CBR", + }, + { + name: "Dubbed inputs with Portuguese replacement eligibility", + meta: api.UploadSubject{ + ReleaseName: "Movie Dubbed 2023 1080p WEB-DL-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL DUAL-GRP", + }, + { + name: "Dubbed inputs without Portuguese replacement eligibility", + meta: api.UploadSubject{ + ReleaseName: "Movie Dubbed 2023 1080p WEB-DL-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English"}, + Tag: "GRP", + }, + want: "Movie Dubbed 2023 1080p WEB-DL-GRP", + }, + { + name: "Dual-Audio inputs with Portuguese replacement eligibility", + meta: api.UploadSubject{ + ReleaseName: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English", "Portuguese"}, + Tag: "GRP", + }, + want: "Movie 2023 1080p WEB-DL DUAL-GRP", + }, + { + name: "Dual-Audio inputs without Portuguese replacement eligibility", + meta: api.UploadSubject{ + ReleaseName: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English"}, + Tag: "GRP", + }, + want: "Movie Dual-Audio 2023 1080p WEB-DL-GRP", + }, + { + name: "Existing DD / DDP / AAC / FLAC normalization", + meta: api.UploadSubject{ + ReleaseName: "Movie DD+ 5.1 DD 5.1 AAC 2.0 FLAC 2.0-GRP", + Release: api.ReleaseInfo{ + Title: "Movie", + Year: 2023, + Group: "GRP", + }, + AudioLanguages: []string{"English"}, + Tag: "GRP", + }, + want: "Movie DDP5.1 DD5.1 AAC2.0 FLAC2.0-GRP", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatLocalizedName(tt.meta, tt.customTag); got != tt.want { + t.Errorf("FormatLocalizedName() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/trackers/impl/unit3d/lst.go b/internal/trackers/impl/unit3d/lst.go deleted file mode 100644 index 30564027..00000000 --- a/internal/trackers/impl/unit3d/lst.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/internal/trackers" -) - -func siteLSTProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - applyAdditionalPayload: func(req trackers.UploadRequest, data map[string]string) { - data["draft_queue_opt_in"] = boolFlag(req.TrackerConfig.Draft) - if editionID, ok := resolveLSTEditionID(req.Meta.Edition); ok { - data["edition_id"] = editionID - } - }, - } -} - -func resolveLSTEditionID(edition string) (string, bool) { - normalized := strings.ToLower(strings.TrimSpace(edition)) - normalized = strings.ReplaceAll(normalized, "’", "'") - mapping := map[string]string{ - "collector's edition": "1", - "director's cut": "2", - "extended cut": "3", - "extended uncut": "4", - "extended unrated": "5", - "limited edition": "6", - "special edition": "7", - "theatrical cut": "8", - "uncut": "9", - "unrated": "10", - "x cut": "11", - "alternative cut": "12", - "other": "0", - } - value, ok := mapping[normalized] - return value, ok -} diff --git a/internal/trackers/impl/unit3d/metadata_policy_test.go b/internal/trackers/impl/unit3d/metadata_policy_test.go new file mode 100644 index 00000000..db6eedca --- /dev/null +++ b/internal/trackers/impl/unit3d/metadata_policy_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "testing" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestMetadataPolicyDefaultsToStrictTMDB(t *testing.T) { + t.Parallel() + + definition := NewWithProfile(Profile{Name: "EXAMPLE"}) + policy := definition.MetadataPolicy() + if len(policy.Requirements) != 1 { + t.Fatalf("requirements = %#v", policy.Requirements) + } + requirement := policy.Requirements[0] + if requirement.Disposition != api.RuleDispositionStrict || + len(requirement.AnyOf) != 1 || requirement.AnyOf[0] != trackers.MetadataFieldTMDB { + t.Fatalf("default requirement = %#v", requirement) + } + + policy.Requirements[0].Disposition = api.RuleDispositionWaivable + if got := definition.MetadataPolicy().Requirements[0].Disposition; got != api.RuleDispositionStrict { + t.Fatalf("mutated default disposition = %q", got) + } +} + +func TestMetadataPolicyAllowsSiteOverride(t *testing.T) { + t.Parallel() + + override := &trackers.TrackerMetadataPolicy{Requirements: []trackers.MetadataRequirement{{ + Scope: trackers.MetadataScopeAny, + AnyOf: []trackers.MetadataField{trackers.MetadataFieldTMDB}, + Disposition: api.RuleDispositionWaivable, + }}} + definition := NewWithProfile(Profile{Name: "EXAMPLE", MetadataPolicy: override}) + override.Requirements[0].Disposition = api.RuleDispositionStrict + override.Requirements[0].AnyOf[0] = trackers.MetadataFieldIMDB + + policy := definition.MetadataPolicy() + if policy.Requirements[0].Disposition != api.RuleDispositionWaivable || + policy.Requirements[0].AnyOf[0] != trackers.MetadataFieldTMDB { + t.Fatalf("site override = %#v", policy.Requirements[0]) + } + + policy.Requirements[0].AnyOf[0] = trackers.MetadataFieldIMDB + if got := definition.MetadataPolicy().Requirements[0].AnyOf[0]; got != trackers.MetadataFieldTMDB { + t.Fatalf("mutated site override field = %q", got) + } +} diff --git a/internal/trackers/impl/unit3d/names.go b/internal/trackers/impl/unit3d/names.go index 0bf1fe23..7f6af9f7 100644 --- a/internal/trackers/impl/unit3d/names.go +++ b/internal/trackers/impl/unit3d/names.go @@ -5,11 +5,8 @@ package unit3d import ( "regexp" - "strconv" "strings" "sync" - "unicode" - "unicode/utf8" "golang.org/x/text/language" "golang.org/x/text/language/display" @@ -25,46 +22,20 @@ var ( languageTagLookup map[string]language.Tag ) -func buildUnit3DName(tracker string, meta api.PreparedMetadata, cfg config.TrackerConfig) string { - trackerName := strings.ToUpper(strings.TrimSpace(tracker)) - if trackerName == "RHD" { - return buildRHDName(meta) +func buildUnit3DName(_ string, meta api.UploadSubject, cfg config.TrackerConfig, profiles ...SiteProfile) string { + profile := firstSiteProfile(profiles) + if profile.BuildName != nil { + return profile.BuildName(meta, cfg) } - name := baseReleaseName(meta) if name == "" { return "" } - switch trackerName { - case "AITHER": - return BuildAitherName(meta) - case "ACM": - return buildACMName(meta) - case "CBR": - return BuildCBRName(meta, cfg.TagForCustomRelease) - case "DP": - return buildDPName(name, meta) - case "LCD": - return BuildCBRName(meta, cfg.TagForCustomRelease) - case "LDU": - return buildLDUName(name, meta) - case "RF": - return addNoGroupSuffix(name, meta, "NoGroup") - case "SAM": - return BuildCBRName(meta, cfg.TagForCustomRelease) - case "OE": - return addNoGroupSuffix(name, meta, "NOGRP") - case "ULCX": - return buildULCXName(name, meta) - case "ZNTH": - return buildZNTHName(name, meta) - default: - return name - } + return name } -func baseReleaseName(meta api.PreparedMetadata) string { +func baseReleaseName(meta api.UploadSubject) string { name := strings.TrimSpace(meta.ReleaseName) if name == "" { name = strings.TrimSpace(meta.ReleaseNameNoTag) @@ -72,47 +43,7 @@ func baseReleaseName(meta api.PreparedMetadata) string { return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) } -func buildDPName(name string, meta api.PreparedMetadata) string { - audioLabel := resolveDPAudioLabel(meta.AudioLanguages) - if audioLabel != "" { - name = strings.Replace(name, "Dual-Audio", audioLabel, 1) - } - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) -} - -func buildULCXName(name string, meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && (strings.Contains(strings.ToLower(strings.TrimSpace(meta.Edition)), "hybrid") || meta.WebDV) { - name = strings.Replace(name, "Hybrid ", "", 1) - } - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) -} - -func resolveDPAudioLabel(languages []string) string { - normalized := make(map[string]struct{}, len(languages)) - for _, language := range languages { - trimmed := strings.TrimSpace(language) - if trimmed == "" { - continue - } - normalized[strings.ToUpper(trimmed)] = struct{}{} - } - - switch len(normalized) { - case 0: - return "" - case 1: - for value := range normalized { - return value - } - return "" - case 2: - return "Dual-Audio" - default: - return "MULTi" - } -} - -func addNoGroupSuffix(name string, meta api.PreparedMetadata, suffix string) string { +func addNoGroupSuffix(name string, meta api.UploadSubject, suffix string) string { tag := strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")) normalizedName := noGroupTagPattern.ReplaceAllString(name, "") normalizedName = strings.TrimSpace(strings.Join(strings.Fields(normalizedName), " ")) @@ -128,54 +59,6 @@ func addNoGroupSuffix(name string, meta api.PreparedMetadata, suffix string) str return normalizedName + "-" + suffix } -func buildLDUName(name string, meta api.PreparedMetadata) string { - catID := resolveUnit3DLDUCategoryID(meta) - nonEnglishOriginal := !isEnglishLanguageToken(resolveOriginalLanguage(meta)) - isoAudio, nonEnglishAudio := firstAudioLanguageCode(meta.AudioLanguages) - isoSubtitle := firstSubtitleLanguageCode(meta.SubtitleLanguages) - - if catID == "18" && isoSubtitle != "" { - return strings.TrimSpace(strings.Join(strings.Fields(name+" [Subs "+isoSubtitle+"]"), " ")) - } - - if !nonEnglishOriginal && !nonEnglishAudio { - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) - } - - languageParts := make([]string, 0, 2) - if isoAudio != "" { - languageParts = append(languageParts, "["+isoAudio+"]") - } - if isoSubtitle != "" { - languageParts = append(languageParts, "[Subs "+isoSubtitle+"]") - } - if len(languageParts) == 0 { - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) - } - - return strings.TrimSpace(strings.Join(strings.Fields(name+" "+strings.Join(languageParts, " ")), " ")) -} - -func firstAudioLanguageCode(languages []string) (string, bool) { - for _, value := range languages { - code, english, ok := languageCode(value) - if ok { - return code, !english - } - } - return "", false -} - -func firstSubtitleLanguageCode(languages []string) string { - for _, value := range languages { - code, _, ok := languageCode(value) - if ok { - return code - } - } - return "" -} - func languageCode(value string) (string, bool, bool) { normalized := languageutil.NormalizeLanguageDisplay(value) if normalized == "" { @@ -231,25 +114,17 @@ func buildLanguageTagLookup() { } } -func resolveOriginalLanguage(meta api.PreparedMetadata) string { +func resolveOriginalLanguage(meta api.UploadSubject) string { switch { - case meta.ExternalMetadata.TMDB != nil && strings.TrimSpace(meta.ExternalMetadata.TMDB.OriginalLanguage) != "": - return strings.TrimSpace(meta.ExternalMetadata.TMDB.OriginalLanguage) - case meta.ExternalMetadata.IMDB != nil && strings.TrimSpace(meta.ExternalMetadata.IMDB.OriginalLanguage) != "": - return strings.TrimSpace(meta.ExternalMetadata.IMDB.OriginalLanguage) + case meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) != "": + return strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) + case meta.ProviderMetadata.IMDB != nil && strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) != "": + return strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) default: return "" } } -func isEnglishLanguageToken(value string) bool { - normalized := languageutil.NormalizeLanguageDisplay(value) - if normalized != "" { - value = normalized - } - return isEnglishLanguageTag(strings.TrimSpace(value)) -} - func isEnglishLanguageTag(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "english", "en", "eng", "en-us", "en-gb": @@ -268,209 +143,3 @@ func isNoGroupTag(tag string) bool { return false } } - -// buildZNTHName applies ZNTH release-name policy before upload. -// TV names drop episode-title text when it appears before the resolution, while -// non-TV names prefer the IMDb year when it disagrees with the parsed release year. -func buildZNTHName(name string, meta api.PreparedMetadata) string { - category := resolveUnit3DCategory(meta) - if category == "TV" && strings.TrimSpace(meta.EpisodeTitle) != "" { - resolution := resolveResolution(meta) - if resolution != "" { - name = replaceZNTHEpisodeTitle(name, meta.EpisodeTitle, resolution) - } - } - - if category != "TV" { - imdbYear := 0 - if meta.ExternalMetadata.IMDB != nil { - imdbYear = meta.ExternalMetadata.IMDB.Year - } - year := meta.Release.Year - if imdbYear > 0 && year > 0 && imdbYear != year { - name = replaceZNTHMovieYear(name, meta, year, imdbYear) - } - } - return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) -} - -// replaceZNTHEpisodeTitle removes the episode-title segment only when its -// normalized text appears immediately before a matching resolution token. -func replaceZNTHEpisodeTitle(name string, episodeTitle string, resolution string) string { - normalizedTitle := normalizeZNTHAlphaNum(episodeTitle) - if normalizedTitle == "" { - return name - } - - for _, resolutionStart := range findZNTHTokenIndexes(name, resolution) { - titleStart, ok := findZNTHTitleStartBefore(name[:resolutionStart], normalizedTitle) - if !ok { - continue - } - return name[:titleStart] + name[resolutionStart:] - } - return name -} - -// findZNTHTitleStartBefore returns the byte offset of the trailing segment in -// prefix whose alphanumeric-normalized text matches normalizedTitle. -func findZNTHTitleStartBefore(prefix string, normalizedTitle string) (int, bool) { - candidates := []int{0} - for i, r := range prefix { - if !isZNTHAlphaNum(r) { - candidates = append(candidates, i+len(string(r))) - } - } - - for i := len(candidates) - 1; i >= 0; i-- { - start := candidates[i] - if normalizeZNTHAlphaNum(prefix[start:]) == normalizedTitle { - return start, true - } - } - return 0, false -} - -// replaceZNTHMovieYear replaces the parsed release-year token before the first -// matching resolution token, or before a trailing metadata release-group suffix -// when no resolution is known. -func replaceZNTHMovieYear(name string, meta api.PreparedMetadata, year int, imdbYear int) string { - yearToken := strconv.Itoa(year) - yearIndexes := findZNTHTokenIndexes(name, yearToken) - if len(yearIndexes) == 0 { - return name - } - - searchEnd := len(name) - if resolution := resolveResolution(meta); resolution != "" { - resolutionIndexes := findZNTHTokenIndexes(name, resolution) - if len(resolutionIndexes) > 0 { - searchEnd = resolutionIndexes[0] - } - } else if groupStart, ok := findZNTHReleaseGroupStart(name, meta.Release.Group); ok { - searchEnd = groupStart - } - - replaceStart := -1 - for _, yearStart := range yearIndexes { - if yearStart < searchEnd { - replaceStart = yearStart - } - } - if replaceStart == -1 { - return name - } - - replacement := strconv.Itoa(imdbYear) - return name[:replaceStart] + replacement + name[replaceStart+len(yearToken):] -} - -// findZNTHTokenIndexes returns original-string byte offsets for -// case-insensitive token matches bounded by non-alphanumeric ZNTH separators. -func findZNTHTokenIndexes(value string, token string) []int { - token = strings.TrimSpace(token) - if token == "" { - return nil - } - - tokenRunes := utf8.RuneCountInString(token) - indexes := []int{} - for start := range value { - end, ok := endAfterZNTHRunes(value, start, tokenRunes) - if !ok { - break - } - if strings.EqualFold(value[start:end], token) && hasZNTHTokenBoundaries(value, start, end) { - indexes = append(indexes, start) - } - } - return indexes -} - -// findZNTHReleaseGroupStart returns the byte offset of a trailing "-group" -// suffix only when group is a real parsed release group. -func findZNTHReleaseGroupStart(name string, group string) (int, bool) { - group = strings.TrimSpace(group) - if group == "" || isNoGroupTag(group) { - return 0, false - } - - trimmedName := strings.TrimRightFunc(name, unicode.IsSpace) - groupStart, ok := foldSuffixStart(trimmedName, group) - if !ok { - return 0, false - } - - boundary := groupStart - for boundary > 0 { - r, size := utf8.DecodeLastRuneInString(trimmedName[:boundary]) - if !unicode.IsSpace(r) { - break - } - boundary -= size - } - if boundary > 0 && trimmedName[boundary-1] == '-' { - return boundary - 1, true - } - return 0, false -} - -// foldSuffixStart returns the byte offset where suffix starts when value ends -// with suffix under Unicode case folding. -func foldSuffixStart(value string, suffix string) (int, bool) { - start := len(value) - for range suffix { - if start == 0 { - return 0, false - } - _, size := utf8.DecodeLastRuneInString(value[:start]) - start -= size - } - return start, strings.EqualFold(value[start:], suffix) -} - -// endAfterZNTHRunes returns the byte offset after count runes from start. -func endAfterZNTHRunes(value string, start int, count int) (int, bool) { - end := start - for range count { - if end >= len(value) { - return 0, false - } - _, size := utf8.DecodeRuneInString(value[end:]) - end += size - } - return end, true -} - -// hasZNTHTokenBoundaries reports whether start and end are outside adjacent -// letters or digits in value. -func hasZNTHTokenBoundaries(value string, start int, end int) bool { - if start > 0 { - r, _ := utf8.DecodeLastRuneInString(value[:start]) - if isZNTHAlphaNum(r) { - return false - } - } - if end < len(value) { - r, _ := utf8.DecodeRuneInString(value[end:]) - if isZNTHAlphaNum(r) { - return false - } - } - return true -} - -// normalizeZNTHAlphaNum lowercases value and drops every non-alphanumeric rune. -func normalizeZNTHAlphaNum(value string) string { - var b strings.Builder - for _, r := range value { - if isZNTHAlphaNum(r) { - b.WriteRune(unicode.ToLower(r)) - } - } - return b.String() -} - -func isZNTHAlphaNum(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) -} diff --git a/internal/trackers/impl/unit3d/oe.go b/internal/trackers/impl/unit3d/oe.go deleted file mode 100644 index 9601ea8c..00000000 --- a/internal/trackers/impl/unit3d/oe.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteOEProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DOETypeID} -} - -func resolveUnit3DOETypeID(meta api.PreparedMetadata) string { - typeValue := inferUnit3DType(meta) - if typeValue == "DVDRIP" { - typeValue = "ENCODE" - } - - switch typeValue { - case "DISC": - return "19" - case "REMUX": - return "20" - case "WEBDL": - return "21" - case "WEBRIP", "ENCODE": - switch normalizeUnit3DVideoCodec(meta.VideoCodec) { - case "HEVC": - return "10" - case "AV1": - return "14" - case "AVC": - return "15" - default: - return "16" - } - default: - return "16" - } -} - -func normalizeUnit3DVideoCodec(value string) string { - codec := strings.ToUpper(strings.TrimSpace(value)) - switch { - case strings.Contains(codec, "AV1"): - return "AV1" - case strings.Contains(codec, "HEVC") || strings.Contains(codec, "H.265") || strings.Contains(codec, "H265") || strings.Contains(codec, "X265"): - return "HEVC" - case strings.Contains(codec, "AVC") || strings.Contains(codec, "H.264") || strings.Contains(codec, "H264") || strings.Contains(codec, "X264"): - return "AVC" - default: - return "" - } -} diff --git a/internal/trackers/impl/unit3d/otw.go b/internal/trackers/impl/unit3d/otw.go deleted file mode 100644 index 65dd49e0..00000000 --- a/internal/trackers/impl/unit3d/otw.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteOTWProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DOTWTypeID} -} - -func resolveUnit3DOTWTypeID(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - return "1" - } - if isDiscType(meta.DiscType) { - return "7" - } - - typeValue := inferUnit3DType(meta) - if typeValue == "DVDRIP" { - return "8" - } - - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - "ENCODE": "3", - } - return mapping[typeValue] -} diff --git a/internal/trackers/impl/unit3d/profiles.go b/internal/trackers/impl/unit3d/profiles.go index 40a9c713..49334d23 100644 --- a/internal/trackers/impl/unit3d/profiles.go +++ b/internal/trackers/impl/unit3d/profiles.go @@ -4,51 +4,36 @@ package unit3d import ( - "strings" + "context" + "github.com/autobrr/upbrr/internal/config" "github.com/autobrr/upbrr/internal/trackers" "github.com/autobrr/upbrr/pkg/api" ) -type unit3DSiteProfile struct { - resolveTypeID func(meta api.PreparedMetadata) string - resolveResolutionID func(meta api.PreparedMetadata) string - resolveCategoryID func(meta api.PreparedMetadata) string - applyAdditionalPayload func(req trackers.UploadRequest, data map[string]string) +// SiteProfile contains optional site-owned Unit3D payload callbacks. +type SiteProfile struct { + // BuildName optionally overrides the generic Unit3D release-name builder. + BuildName func(meta api.UploadSubject, cfg config.TrackerConfig) string + // BuildDescription optionally renders site-specific tracker markup. + BuildDescription func(ctx context.Context, meta api.UploadSubject, appConfig config.Config, trackerConfig config.TrackerConfig, logger api.Logger, keptDescription string, menuImages []api.ScreenshotImage, screenshots []api.ScreenshotImage) (string, error) + // ResolveKeywords optionally maps prepared metadata to Unit3D keywords. + ResolveKeywords func(meta api.UploadSubject) string + // ResolveTypeID optionally maps prepared metadata to a site type identifier. + ResolveTypeID func(meta api.UploadSubject) string + // ResolveResolutionID optionally maps prepared metadata to a site resolution identifier. + ResolveResolutionID func(meta api.UploadSubject) string + // ResolveCategoryID optionally maps prepared metadata to a site category identifier. + ResolveCategoryID func(meta api.UploadSubject) string + // ApplyAdditionalPayload appends site-owned fields to a prepared payload. + ApplyAdditionalPayload func(req trackers.PreparationInput, data map[string]string) + // FinalizeDescription applies final site-owned description transformations. + FinalizeDescription func(description string, meta api.UploadSubject) string } -var unit3DSiteProfiles = map[string]unit3DSiteProfile{ - "A4K": siteA4KProfile(), - "ACM": siteACMProfile(), - "AITHER": siteAITHERProfile(), - "BLU": siteBLUProfile(), - "CBR": siteCBRProfile(), - "EMUW": siteEMUWProfile(), - "IHD": siteIHDProfile(), - "ITT": siteITTProfile(), - "LDU": siteLDUProfile(), - "LST": siteLSTProfile(), - "OE": siteOEProfile(), - "OTW": siteOTWProfile(), - "PT": sitePTProfile(), - "R4E": siteR4EProfile(), - "RF": siteRFProfile(), - "RHD": siteRHDProfile(), - "SHRI": siteSHRIProfile(), - "STC": siteSTCProfile(), - "TIK": siteTIKProfile(), - "TLZ": siteTLZProfile(), - "TOS": siteTOSProfile(), - "UTP": siteUTPProfile(), - "YUS": siteYUSProfile(), - "ZNTH": siteZNTHProfile(), -} - -func unit3DSiteProfileFor(tracker string) (unit3DSiteProfile, bool) { - key := strings.ToUpper(strings.TrimSpace(tracker)) - if key == "" { - return unit3DSiteProfile{}, false +func firstSiteProfile(profiles []SiteProfile) SiteProfile { + if len(profiles) == 0 { + return SiteProfile{} } - profile, ok := unit3DSiteProfiles[key] - return profile, ok + return profiles[0] } diff --git a/internal/trackers/impl/unit3d/pt.go b/internal/trackers/impl/unit3d/pt.go deleted file mode 100644 index 69430110..00000000 --- a/internal/trackers/impl/unit3d/pt.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func sitePTProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DPTTypeID, - resolveResolutionID: resolveUnit3DPTResolutionID, - applyAdditionalPayload: func(req trackers.UploadRequest, data map[string]string) { - data["audio_pt"] = boolFlag(hasEuropeanPortuguese(req.Meta.AudioLanguages)) - data["legenda_pt"] = boolFlag(hasEuropeanPortuguese(req.Meta.SubtitleLanguages)) - }, - } -} - -func hasEuropeanPortuguese(languages []string) bool { - for _, language := range languages { - lower := strings.ToLower(strings.TrimSpace(language)) - if lower == "" { - continue - } - if strings.Contains(lower, "brazil") || strings.Contains(lower, "brasil") || strings.Contains(lower, "pt-br") || strings.Contains(lower, "ptbr") { - continue - } - if strings.Contains(lower, "portuguese") || strings.EqualFold(lower, "pt") || strings.Contains(lower, "português") { - return true - } - } - return false -} - -func resolveUnit3DPTTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "WEBDL": "4", - "WEBRIP": "39", - "HDTV": "6", - "ENCODE": "3", - } - return mapping[inferUnit3DType(meta)] -} - -func resolveUnit3DPTResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1440p": "13", - "1080p": "3", - "1080i": "4", - "720p": "5", - "576p": "6", - "576i": "7", - "540p": "11", - "480p": "8", - "480i": "9", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/r4e.go b/internal/trackers/impl/unit3d/r4e.go deleted file mode 100644 index e8d539fb..00000000 --- a/internal/trackers/impl/unit3d/r4e.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteR4EProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveCategoryID: resolveUnit3DR4ECategoryID} -} - -func resolveUnit3DR4ECategoryID(meta api.PreparedMetadata) string { - genreIDs := "" - if meta.ExternalMetadata.TMDB != nil { - genreIDs = strings.TrimSpace(meta.ExternalMetadata.TMDB.GenreIDs) - } - isDoc := false - if genreIDs != "" { - for value := range strings.SplitSeq(genreIDs, ",") { - if strings.TrimSpace(value) == "99" { - isDoc = true - break - } - } - } - category := resolveUnit3DCategory(meta) - if strings.EqualFold(category, "MOVIE") { - if isDoc { - return "66" - } - return "70" - } - if strings.EqualFold(category, "TV") { - if isDoc { - return "2" - } - return "79" - } - return "24" -} diff --git a/internal/trackers/impl/unit3d/rf.go b/internal/trackers/impl/unit3d/rf.go deleted file mode 100644 index 0df2a60f..00000000 --- a/internal/trackers/impl/unit3d/rf.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteRFProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DRFTypeID, - resolveResolutionID: resolveUnit3DRFResolutionID, - } -} - -func resolveUnit3DRFTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "43", - "REMUX": "40", - "WEBDL": "42", - "WEBRIP": "45", - "ENCODE": "41", - "HDTV": "35", - } - return mapping[inferUnit3DType(meta)] -} - -func resolveUnit3DRFResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1080p": "3", - "1080i": "4", - "720p": "5", - "576p": "6", - "576i": "7", - "480p": "8", - "480i": "9", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} diff --git a/internal/trackers/impl/unit3d/rhd.go b/internal/trackers/impl/unit3d/rhd.go deleted file mode 100644 index a8494485..00000000 --- a/internal/trackers/impl/unit3d/rhd.go +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "regexp" - "slices" - "strconv" - "strings" - - "golang.org/x/text/language" - "golang.org/x/text/language/display" - - "github.com/autobrr/upbrr/pkg/api" -) - -var ( - rhdRegradedRegex = rhdTokenRegex(`regraded`) - rhdUpscaleRegex = rhdTokenRegex(`upscaled?`, `upscl`, `upsuhd`) - rhdInternalRegex = rhdTokenRegex(`internal`) - rhdIncompleteRegex = rhdTokenRegex(`incomplete`) - rhdDubbedRegex = rhdTokenRegex(`dubbed`, `synced`, `ac3d`, `ld`, `line`, `mic`, `md`) -) - -// rhdTokenRegex matches RHD marker tokens only when they are delimited outside -// alphanumeric release-name text. -func rhdTokenRegex(tokens ...string) *regexp.Regexp { - return regexp.MustCompile(`(?i)(^|[^[:alnum:]])(?:` + strings.Join(tokens, "|") + `)([^[:alnum:]]|$)`) -} - -func siteRHDProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveResolutionID: resolveUnit3DRHDResolutionID, - } -} - -func resolveUnit3DRHDResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1080p": "3", - "1080i": "4", - "720p": "5", - "576p": "12", - "576i": "13", - "480p": "11", - "480i": "18", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "10" -} - -// buildRHDName formats RocketHD names. Full-disc names intentionally omit the -// language tag segment even though RHD upload rules still require German audio. -func buildRHDName(meta api.PreparedMetadata) string { - parts := make([]string, 0) - isFullDisc := strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") || isDiscType(meta.DiscType) - markerText := rhdMarkerText(meta) - - // 1. Title (German title preferred) - title := "" - tmdb := meta.ExternalMetadata.TMDB - if tmdb != nil && tmdb.LocalizedTitles != nil { - if t, ok := tmdb.LocalizedTitles["de"]; ok && strings.TrimSpace(t) != "" { - title = strings.TrimSpace(t) - } - } - if title == "" { - title = strings.TrimSpace(meta.Release.Title) - } - if title == "" && tmdb != nil { - title = strings.TrimSpace(tmdb.Title) - } - if title == "" && tmdb != nil { - title = strings.TrimSpace(tmdb.OriginalTitle) - } - if title != "" { - parts = append(parts, title) - } - - // 2. Year (always) - year := meta.Release.Year - if year == 0 && meta.ExternalMetadata.TMDB != nil { - year = meta.ExternalMetadata.TMDB.Year - } - yearStr := strconv.Itoa(year) - if year == 0 { - yearStr = "0000" - } - parts = append(parts, yearStr) - - // 2.5 Season / Episode - if meta.SeasonStr != "" || meta.EpisodeStr != "" { - parts = append(parts, strings.TrimSpace(meta.SeasonStr+meta.EpisodeStr)) - - if rhdIncompleteRegex.MatchString(markerText) { - parts = append(parts, "iNCOMPLETE") - } - } - - // 3. CUT / Edition / 3D - if meta.Edition != "" { - parts = append(parts, meta.Edition) - } - if meta.Is3D != "" { - parts = append(parts, meta.Is3D) - } - - // 4. Language. RHD full-disc naming uses COMPLETE/region/source details - // instead of GERMAN/DL/ML language tags. - if !isFullDisc { - parts = append(parts, resolveRHDLanguage(meta)) - } - - // 5. REPACK - if meta.Repack != "" { - parts = append(parts, meta.Repack) - } - - // 6. Resolution - if meta.Release.Resolution != "" { - parts = append(parts, meta.Release.Resolution) - - // Rocket-HD naming requires REGRADED and UPSCALE tags after resolution - if rhdRegradedRegex.MatchString(markerText) { - parts = append(parts, "REGRADED") - } - if rhdUpscaleRegex.MatchString(markerText) { - parts = append(parts, "UPSCALE") - } - } - - // 7. Service/UHD - if strings.Contains(strings.ToUpper(meta.Type), "WEB") && meta.Service != "" { - parts = append(parts, meta.Service) - } else if meta.UHD != "" { - parts = append(parts, meta.UHD) - } - - // 8. Type/Source - parts = append(parts, resolveRHDTypeAndSource(meta)...) - - // 9. AudioCodec & Channels - if meta.Audio != "" { - parts = append(parts, meta.Audio) - } - if meta.Channels != "" && !strings.Contains(meta.Audio, meta.Channels) { - parts = append(parts, meta.Channels) - } - - // 10. HDR - if meta.HDR != "" { - parts = append(parts, meta.HDR) - } - - // 11. BitDepth - if meta.BitDepth != "" && meta.BitDepth != "8" && meta.BitDepth != "0" { - if strings.Contains(strings.ToLower(meta.BitDepth), "bit") { - parts = append(parts, meta.BitDepth) - } else { - parts = append(parts, meta.BitDepth+"bit") - } - } - - // 12. VideoCodec - videoCodec := meta.VideoEncode - if videoCodec == "" { - videoCodec = meta.VideoCodec - } - if videoCodec != "" { - parts = append(parts, videoCodec) - } - - // 13. INTERNAL - if rhdInternalRegex.MatchString(markerText) { - parts = append(parts, "iNTERNAL") - } - - // Group - group := meta.Tag - if group == "" || isNoGroupTag(group) { - group = "NOGRP" - } else { - group = strings.TrimPrefix(group, "-") - } - - name := strings.Join(parts, " ") - name = strings.Join(strings.Fields(name), " ") - return name + "-" + group -} - -// resolveRHDTypeAndSource formats RHD source/type segments, promoting an empty -// type with a disc-like DiscType to the full-disc COMPLETE form. -func resolveRHDTypeAndSource(meta api.PreparedMetadata) []string { - var parts []string - typeName := strings.TrimSpace(meta.Type) - if typeName == "" && isDiscType(meta.DiscType) { - typeName = "DISC" - } - if typeName == "" { - return nil - } - - switch strings.ToUpper(typeName) { - case "WEBDL": - typeName = "WEB-DL" - case "WEBRIP": - typeName = "WEBRip" - case "ENCODE": - if meta.Source != "" { - parts = append(parts, meta.Source) - typeName = "" - } - case "REMUX": - if meta.Source != "" { - parts = append(parts, meta.Source) - } - case "DISC": - parts = append(parts, "COMPLETE") - if meta.Region != "" { - parts = append(parts, meta.Region) - } - if meta.Release.Source != "" { - parts = append(parts, meta.Release.Source) - } - if meta.Release.Size != "" { - parts = append(parts, meta.Release.Size) - } - typeName = "" - } - - if typeName != "" { - parts = append(parts, typeName) - } - return parts -} - -// rhdMarkerText strips the terminal release group tag before scanning for RHD -// marker tokens, so short group tags do not look like language or source flags. -func rhdMarkerText(meta api.PreparedMetadata) string { - value := strings.TrimSpace(meta.ReleaseName) - tag := strings.TrimSpace(meta.Tag) - if value == "" || tag == "" { - return value - } - tag = strings.TrimPrefix(tag, "-") - if tag == "" { - return value - } - for _, suffix := range []string{"-" + tag, "." + tag, "_" + tag, " " + tag} { - if strings.HasSuffix(strings.ToLower(value), strings.ToLower(suffix)) { - return strings.TrimSpace(value[:len(value)-len(suffix)]) - } - } - return value -} - -// resolveRHDLanguage builds the RHD language segment from unique parseable audio -// languages, with German subtitle-only and dubbed markers handled separately. -func resolveRHDLanguage(meta api.PreparedMetadata) string { - audioLanguages := normalizedRHDAudioLanguages(meta.AudioLanguages) - hasGermanAudio := slices.ContainsFunc(audioLanguages, isGermanLanguage) - - numAudio := len(audioLanguages) - var baseTag string - - if hasGermanAudio { - baseTag = "GERMAN" - } else { - // No German audio, check subs - hasGermanSubs := slices.ContainsFunc(meta.SubtitleLanguages, isGermanLanguage) - if hasGermanSubs { - return "GERMAN SUBBED" - } - - // Use main language name - if numAudio > 0 { - baseTag = getRHDLanguageName(audioLanguages[0]) - } else { - baseTag = "ENGLISH" - } - } - - isDubbed := rhdDubbedRegex.MatchString(rhdMarkerText(meta)) - if isDubbed { - baseTag += " DUBBED" - } - - if numAudio == 2 { - return baseTag + " DL" - } else if numAudio > 2 { - return baseTag + " ML" - } - return baseTag -} - -// normalizedRHDAudioLanguages returns first-seen base language subtags, dropping -// blank, unparsable, undefined, and duplicate audio entries before DL/ML counting. -func normalizedRHDAudioLanguages(values []string) []string { - languages := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - tag, ok := parseLanguageTag(value) - if !ok { - continue - } - base, _ := tag.Base() - key := base.String() - if key == "" || key == "und" { - continue - } - if isGermanLanguage(key) { - key = "de" - } - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - languages = append(languages, key) - } - return languages -} - -// isGermanLanguage accepts common German names, ISO codes, and Swiss German -// tags for RHD audio and subtitle language decisions. -func isGermanLanguage(l string) bool { - tag, ok := parseLanguageTag(l) - if !ok { - l = strings.ToLower(strings.TrimSpace(l)) - return slices.Contains([]string{"german", "ger", "de", "deu", "gsw"}, l) - } - base, _ := tag.Base() - switch base.String() { - case "de", "gsw": - return true - default: - return false - } -} - -// getRHDLanguageName returns the English display name RHD expects for a -// parseable language tag, falling back to the uppercased input. -func getRHDLanguageName(l string) string { - tag, ok := parseLanguageTag(l) - if !ok { - return strings.ToUpper(strings.TrimSpace(l)) - } - name := display.Languages(language.English).Name(tag) - if name == "" { - return strings.ToUpper(strings.TrimSpace(l)) - } - return strings.ToUpper(name) -} diff --git a/internal/trackers/impl/unit3d/shri.go b/internal/trackers/impl/unit3d/shri.go deleted file mode 100644 index f3fc9343..00000000 --- a/internal/trackers/impl/unit3d/shri.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/internal/trackers" - "github.com/autobrr/upbrr/pkg/api" -) - -func siteSHRIProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DSHRITypeID, - applyAdditionalPayload: func(req trackers.UploadRequest, data map[string]string) { - if regionID := numericValue(req.Meta.Region); regionID != "" { - data["region_id"] = regionID - } - if distributorID := numericValue(req.Meta.Distributor); distributorID != "" { - data["distributor_id"] = distributorID - } - }, - } -} - -func resolveUnit3DSHRITypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "26", - "REMUX": "7", - "WEBDL": "27", - "WEBRIP": "15", - "HDTV": "33", - "ENCODE": "15", - "DVDRIP": "15", - } - return mapping[inferUnit3DType(meta)] -} - -func applySHRIDescriptionNotes(description string, meta api.PreparedMetadata) string { - if !strings.EqualFold(resolveSHRIReleaseGroup(meta), "island") { - return description - } - const shareislandNotes = "Release Shareisland 🏴‍☠️\nFalla girare, condividila e contribuisci a mantenerla viva restando in seed il più possi" + "bile.\nGrazie per il supporto!" - trimmed := strings.TrimSpace(description) - if strings.Contains(trimmed, shareislandNotes) { - return trimmed - } - if trimmed == "" { - return shareislandNotes - } - return trimmed + "\n\n" + shareislandNotes -} - -func resolveSHRIReleaseGroup(meta api.PreparedMetadata) string { - for _, value := range []string{meta.Release.Group, meta.ArrReleaseGroup, strings.TrimPrefix(meta.Tag, "-")} { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func numericValue(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "" - } - for _, r := range trimmed { - if r < '0' || r > '9' { - return "" - } - } - return trimmed -} diff --git a/internal/trackers/impl/unit3d/site_contract.go b/internal/trackers/impl/unit3d/site_contract.go new file mode 100644 index 00000000..bbaa4666 --- /dev/null +++ b/internal/trackers/impl/unit3d/site_contract.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package unit3d + +import ( + "golang.org/x/text/language" + + "github.com/autobrr/upbrr/pkg/api" +) + +// InferType returns the common normalized Unit3D release type. +func InferType(meta api.UploadSubject) string { return inferUnit3DType(meta) } + +// Resolution returns the common normalized release resolution. +func Resolution(meta api.UploadSubject) string { return resolveResolution(meta) } + +// RuleResolution returns the common normalized release resolution from the +// narrower rule-evaluation read model. +func RuleResolution(meta api.RuleSubject) string { + return resolveResolutionValues(meta.Release, meta.ReleaseName) +} + +// Category returns the common normalized Unit3D category. +func Category(meta api.UploadSubject) string { return resolveUnit3DCategory(meta) } + +// DefaultCategoryID returns the common Unit3D category mapping. +func DefaultCategoryID(meta api.UploadSubject) string { return resolveUnit3DCategoryID(meta) } + +// IsDiscType reports whether value is a supported disc marker. +func IsDiscType(value string) bool { return isDiscType(value) } + +// IsSDResolution reports whether value is a standard-definition resolution. +func IsSDResolution(value string) bool { return isSDResolution(value) } + +// Keywords returns common TMDB keyword metadata. +func Keywords(meta api.UploadSubject) string { return resolveKeywords(meta) } + +// TMDBGenres returns common TMDB genre metadata. +func TMDBGenres(meta api.UploadSubject) string { return resolveTMDBGenres(meta) } + +// IMDBGenres returns common IMDb genre metadata. +func IMDBGenres(meta api.UploadSubject) string { return resolveIMDBGenres(meta) } + +// HasEnglishLanguage reports whether languages contains an English marker. +func HasEnglishLanguage(languages []string) bool { return hasEnglishLanguage(languages) } + +// HasAdultToken reports whether value contains a common adult-content marker. +func HasAdultToken(value string) bool { return hasAdultToken(value) } + +// ParseLanguageTag resolves common language names and codes. +func ParseLanguageTag(value string) (language.Tag, bool) { return parseLanguageTag(value) } + +// IsNoGroupTag reports whether tag is a no-group placeholder. +func IsNoGroupTag(tag string) bool { return isNoGroupTag(tag) } diff --git a/internal/trackers/impl/unit3d/site_helpers.go b/internal/trackers/impl/unit3d/site_helpers.go index de35ed97..331db1ff 100644 --- a/internal/trackers/impl/unit3d/site_helpers.go +++ b/internal/trackers/impl/unit3d/site_helpers.go @@ -9,16 +9,16 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) -func resolveTMDBGenres(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Genres) +func resolveTMDBGenres(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Genres) } return "" } -func resolveIMDBGenres(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.IMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.IMDB.Genres) +func resolveIMDBGenres(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.Genres) } return "" } diff --git a/internal/trackers/impl/unit3d/sites/a4k/banned_groups.go b/internal/trackers/impl/unit3d/sites/a4k/banned_groups.go new file mode 100644 index 00000000..21383c4a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/a4k/banned_groups.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package a4k + +// BannedGroups returns A4K's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "BiTOR", + "DepraveD", + "Flights", + "SasukeducK", + "SPDVD", + "TEKNO3D", + } +} diff --git a/internal/trackers/impl/unit3d/sites/a4k/profile.go b/internal/trackers/impl/unit3d/sites/a4k/profile.go new file mode 100644 index 00000000..8fb9597c --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/a4k/profile.go @@ -0,0 +1,41 @@ +package a4k + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns A4K's restricted type and resolution mappings together with +// its tracker-owned rules, banned groups, and image-host policy. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "A4K", + BaseURL: "https://aura4k.net", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + }, + ImageHost: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"onlyimage", "imgbox", "ptscreens", "imgbb", "imgur", "postimg"}, + }, + } +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "WEBDL": "4", + "ENCODE": "3", + }[unit3d.InferType(meta)] +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{"4320p": "1", "2160p": "2"}[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/a4k/rules.go b/internal/trackers/impl/unit3d/sites/a4k/rules.go new file mode 100644 index 00000000..443a399e --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/a4k/rules.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package a4k + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns A4K's strict non-disc English audio-or-subtitle requirement, +// which accepts original-language audio when English subtitles are present. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonDisc: true, + }} +} diff --git a/internal/trackers/impl/unit3d/sites/acm/profile.go b/internal/trackers/impl/unit3d/sites/acm/profile.go new file mode 100644 index 00000000..126e3b08 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/acm/profile.go @@ -0,0 +1,580 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package acm + +import ( + "context" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + acmSceneNFOPattern = regexp.MustCompile(`(?is)\[center\]\[spoiler=Scene NFO:\].*?\[/center\]`) + acmSubtitleCodes = map[string]string{ + "arabic": "Ara", + "ara": "Ara", + "ar": "Ara", + "brazilian portuguese": "Por-BR", + "brazilian": "Por-BR", + "portuguese-br": "Por-BR", + "pt-br": "Por-BR", + "bulgarian": "Bul", + "bul": "Bul", + "bg": "Bul", + "chinese": "Chi", + "chi": "Chi", + "zh": "Chi", + "chinese (simplified)": "Chi", + "chinese (traditional)": "Chi", + "croatian": "Cro", + "hrv": "Cro", + "hr": "Cro", + "scr": "Cro", + "czech": "Cze", + "cze": "Cze", + "cz": "Cze", + "cs": "Cze", + "danish": "Dan", + "dan": "Dan", + "da": "Dan", + "dutch": "Dut", + "dut": "Dut", + "nl": "Dut", + "english": "Eng", + "eng": "Eng", + "en": "Eng", + "english (cc)": "Eng", + "english - sdh": "Eng", + "english - forced": "Eng", + "english (forced)": "Eng", + "en (forced)": "Eng", + "english intertitles": "Eng", + "english (intertitles)": "Eng", + "english - intertitles": "Eng", + "en (intertitles)": "Eng", + "estonian": "Est", + "est": "Est", + "et": "Est", + "finnish": "Fin", + "fin": "Fin", + "fi": "Fin", + "french": "Fre", + "fre": "Fre", + "fr": "Fre", + "german": "Ger", + "ger": "Ger", + "de": "Ger", + "greek": "Gre", + "gre": "Gre", + "el": "Gre", + "hebrew": "Heb", + "heb": "Heb", + "he": "Heb", + "hindi": "Hin", + "hin": "Hin", + "hi": "Hin", + "hungarian": "Hun", + "hun": "Hun", + "hu": "Hun", + "icelandic": "Ice", + "ice": "Ice", + "is": "Ice", + "indonesian": "Ind", + "ind": "Ind", + "id": "Ind", + "italian": "Ita", + "ita": "Ita", + "it": "Ita", + "japanese": "Jpn", + "jpn": "Jpn", + "ja": "Jpn", + "korean": "Kor", + "kor": "Kor", + "ko": "Kor", + "latvian": "Lav", + "lav": "Lav", + "lv": "Lav", + "lithuanian": "Lit", + "lit": "Lit", + "lt": "Lit", + "norwegian": "Nor", + "nor": "Nor", + "no": "Nor", + "persian": "Per", + "fa": "Per", + "far": "Per", + "polish": "Pol", + "pol": "Pol", + "pl": "Pol", + "portuguese": "Por", + "por": "Por", + "pt": "Por", + "romanian": "Rom", + "rum": "Rom", + "ro": "Rom", + "russian": "Rus", + "rus": "Rus", + "ru": "Rus", + "serbian": "Ser", + "srp": "Ser", + "sr": "Ser", + "scc": "Ser", + "slovak": "Slo", + "slo": "Slo", + "sk": "Slo", + "slovenian": "Slv", + "slv": "Slv", + "sl": "Slv", + "spanish": "Spa", + "spa": "Spa", + "es": "Spa", + "swedish": "Swe", + "swe": "Swe", + "sv": "Swe", + "thai": "Tha", + "tha": "Tha", + "th": "Tha", + "turkish": "Tur", + "tur": "Tur", + "tr": "Tur", + "ukrainian": "Ukr", + "ukr": "Ukr", + "uk": "Ukr", + "vietnamese": "Vie", + "vie": "Vie", + "vi": "Vie", + } +) + +// Profile returns ACM's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "ACM", + BaseURL: "https://eiga.moi", + DescriptionGroup: "acm", + UploadArtifact: &trackers.UploadArtifactPolicy{ + Source: "AsianCinema", + }, + Site: unit3d.SiteProfile{ + BuildName: buildName, + BuildDescription: buildACMDescription, + ResolveKeywords: resolveACMKeywords, + ResolveTypeID: resolveUnit3DACMTypeID, + ResolveResolutionID: resolveUnit3DACMResolutionID, + ApplyAdditionalPayload: additionalPayload, + }, + } +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { return buildACMName(meta) } + +func additionalPayload(req trackers.PreparationInput, data map[string]string) { + if regionID := numericValue(req.Meta.Region); regionID != "" { + data["region_id"] = regionID + } + if distributorID := numericValue(req.Meta.Distributor); distributorID != "" { + data["distributor_id"] = distributorID + } +} + +func resolveUnit3DACMTypeID(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + sizeBucket := acmDiscBucket(meta.SourceSize) + if strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") && sizeBucket != 25 { + switch sizeBucket { + case 50: + return "3" + case 66: + return "2" + case 100: + return "1" + } + } + switch sizeBucket { + case 25: + return "5" + case 50: + return "4" + } + return "0" + } + + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + switch acmDVDType(meta) { + case "DVD 5": + return "14" + case "DVD 9": + return "16" + default: + return "0" + } + } + + switch acmNonDiscType(meta) { + case "UHD REMUX": + return "12" + case "REMUX": + return "7" + case "WEBDL": + return "9" + case "SDTV": + return "13" + case "HDTV": + return "17" + default: + return "0" + } +} + +func resolveUnit3DACMResolutionID(meta api.UploadSubject) string { + switch strings.ToLower(strings.TrimSpace(unit3d.Resolution(meta))) { + case "2160p": + return "1" + case "1080p", "1080i": + return "2" + case "720p": + return "3" + case "576p", "576i": + return "4" + case "480p", "480i": + return "5" + default: + return "10" + } +} + +func buildACMName(meta api.UploadSubject) string { + name := baseName(meta) + if name == "" { + return "" + } + + title := strings.TrimSpace(resolveACMTitle(meta)) + originalTitle := strings.TrimSpace(resolveACMOriginalTitle(meta)) + if title != "" && originalTitle != "" && !strings.EqualFold(title, originalTitle) { + name = strings.Replace(name, title, title+" / "+originalTitle+" \u202A", 1) + } + + audio := strings.TrimSpace(meta.Audio) + if strings.Contains(audio, "AAC") { + normalizedAudio := strings.Join(strings.Fields(audio), " ") + name = strings.Replace(name, normalizedAudio, strings.ReplaceAll(normalizedAudio, "AAC ", "AAC"), 1) + } + name = strings.ReplaceAll(name, "DD+ ", "DD+") + name = strings.ReplaceAll(name, "UHD BluRay REMUX", "Remux") + name = strings.ReplaceAll(name, "BluRay REMUX", "Remux") + name = strings.ReplaceAll(name, "H.265", "HEVC") + name = strings.ReplaceAll(name, " Atmos", "") + + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + source := strings.TrimSpace(meta.Source) + resolution := strings.TrimSpace(unit3d.Resolution(meta)) + if source != "" && resolution != "" { + name = strings.ReplaceAll(name, source+" DVD5", resolution+" DVD "+source) + name = strings.ReplaceAll(name, source+" DVD9", resolution+" DVD "+source) + } + if audio != "" && audio == strings.TrimSpace(meta.Channels) { + name = strings.ReplaceAll(name, audio, "MPEG "+audio) + } + } + + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) + acmSubtitleTag(acmSubtitleCodesFor(meta)) +} + +func buildACMDescription( + ctx context.Context, + meta api.UploadSubject, + appConfig config.Config, + trackerConfig config.TrackerConfig, + logger api.Logger, + keptDescription string, + menuImages []api.ScreenshotImage, + screenshots []api.ScreenshotImage, +) (string, error) { + base := acmSceneNFOPattern.ReplaceAllString(strings.TrimSpace(keptDescription), "") + base = strings.ReplaceAll(base, "\r\n", "\n") + base = strings.ReplaceAll(base, "[pre]", "[code]") + base = strings.ReplaceAll(base, "[/pre]", "[/code]") + base = strings.ReplaceAll(base, "[hide", "[spoiler") + base = strings.ReplaceAll(base, "[/hide]", "[/spoiler]") + base = convertACMComparisonToCollapse(base, 1000) + base = strings.ReplaceAll(base, "[img]", "[img=300]") + base = descriptionunit3d.AppendDVDVOBMediaInfoBlock(base, api.NewDescriptionSubject(meta)) + + cfg := appConfig + if cfg.Description.ThumbnailSize <= 0 { + cfg.Description.ThumbnailSize = 350 + } + if len(screenshots) > 0 { + cfg.Description.ScreensPerRow = strconv.Itoa(len(screenshots)) + } else if strings.TrimSpace(cfg.Description.ScreensPerRow) == "" { + cfg.Description.ScreensPerRow = "1" + } + + if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && strings.TrimSpace(meta.ServiceLongName) != "" { + header := fmt.Sprintf( + "[center][b][color=#ff00ff][size=18]This release is sourced from %s and is not transcoded, just remuxed from the direct %s stream[/size][/color][/b][/center]", + strings.TrimSpace(meta.ServiceLongName), + strings.TrimSpace(meta.ServiceLongName), + ) + base = strings.TrimSpace(strings.Join([]string{header, base}, "\n")) + } + + value, err := descriptionunit3d.BuildDescription( + ctx, + api.NewDescriptionSubject(meta), + cfg, + trackerConfig, + logger, + base, + menuImages, + screenshots, + ) + if err != nil { + return "", fmt.Errorf("trackers: %w", err) + } + return value, nil +} + +func resolveACMKeywords(meta api.UploadSubject) string { + raw := unit3d.Keywords(meta) + if raw == "" { + return "" + } + parts := strings.Split(raw, ",") + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" || strings.Contains(trimmed, " ") { + continue + } + filtered = append(filtered, trimmed) + if len(filtered) >= 10 { + break + } + } + return strings.Join(filtered, ", ") +} + +func acmDiscBucket(sourceSize int64) int { + if sourceSize <= 0 { + return 100 + } + sizeGiB := float64(sourceSize) / float64(1<<30) + for _, bucket := range []int{25, 50, 66, 100} { + if sizeGiB < float64(bucket) { + return bucket + } + } + return 100 +} + +func acmDVDType(meta api.UploadSubject) string { + name := strings.ToUpper(strings.TrimSpace(baseName(meta))) + switch { + case strings.Contains(name, "DVD5"): + return "DVD 5" + case strings.Contains(name, "DVD9"): + return "DVD 9" + case meta.SourceSize > 0 && meta.SourceSize <= 5*(1<<30): + return "DVD 5" + case meta.SourceSize > 0: + return "DVD 9" + default: + return "" + } +} + +func acmNonDiscType(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(unit3d.InferType(meta)), "REMUX") && strings.EqualFold(strings.TrimSpace(meta.UHD), "UHD") { + return "UHD REMUX" + } + switch strings.ToUpper(strings.TrimSpace(unit3d.InferType(meta))) { + case "WEBDL", "WEB-DL": + return "WEBDL" + case "HDTV": + if strings.HasPrefix(strings.TrimSpace(unit3d.Resolution(meta)), "4") || strings.HasPrefix(strings.TrimSpace(unit3d.Resolution(meta)), "5") { + return "SDTV" + } + return "HDTV" + case "REMUX": + return "REMUX" + default: + return "" + } +} + +func resolveACMTitle(meta api.UploadSubject) string { + for _, value := range []string{ + meta.Release.Title, + resolveACMTMDBTitle(meta), + resolveACMIMDBTitle(meta), + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func resolveACMOriginalTitle(meta api.UploadSubject) string { + for _, value := range []string{ + resolveACMTMDBOriginalTitle(meta), + resolveACMTMDBRetrievedAKA(meta), + resolveACMIMDBAKA(meta), + meta.Release.Alt, + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return strings.TrimPrefix(trimmed, "AKA ") + } + } + return "" +} + +func acmSubtitleCodesFor(meta api.UploadSubject) []string { + out := make([]string, 0, len(meta.SubtitleLanguages)) + seen := make(map[string]struct{}, len(meta.SubtitleLanguages)) + for _, language := range meta.SubtitleLanguages { + key := strings.ToLower(strings.TrimSpace(language)) + if key == "" { + continue + } + code, ok := acmSubtitleCodes[key] + if !ok { + continue + } + if _, exists := seen[code]; exists { + continue + } + seen[code] = struct{}{} + out = append(out, code) + } + return out +} + +func acmSubtitleTag(subtitles []string) string { + if len(subtitles) == 0 { + return " [No subs]" + } + if slices.Contains(subtitles, "Eng") { + return "" + } + if len(subtitles) > 1 { + return " [No Eng subs]" + } + return " [" + subtitles[0] + " subs only]" +} + +// convertACMComparisonToCollapse rewrites UNIT3D comparison blocks into ACM +// spoiler markup while leaving malformed blocks unchanged when no image URLs +// can be paired with the source labels. +func convertACMComparisonToCollapse(value string, maxWidth int) string { + re := regexp.MustCompile(`(?is)\[comparison=[\s\S]*?\[/comparison\]`) + return re.ReplaceAllStringFunc(value, func(block string) string { + parts := strings.SplitN(block, "]", 2) + if len(parts) < 2 { + return block + } + sources := strings.Split(strings.ReplaceAll(strings.TrimPrefix(parts[0], "[comparison="), " ", ""), ",") + if len(sources) == 0 { + return block + } + imgSize := min(maxWidth/len(sources), 350) + body := strings.TrimSuffix(parts[1], "[/comparison]") + fields := strings.Fields(strings.ReplaceAll(body, ",", " ")) + images := make([]string, 0, len(fields)) + for _, field := range fields { + trimmed := strings.TrimSpace(field) + if strings.HasPrefix(strings.ToLower(trimmed), "http://") || strings.HasPrefix(strings.ToLower(trimmed), "https://") { + images = append(images, trimmed) + } + } + if len(images) == 0 { + return block + } + lines := make([]string, 0, len(images)/len(sources)+1) + row := make([]string, 0, len(sources)) + for _, image := range images { + row = append(row, fmt.Sprintf("[url=%s][img=%d]%s[/img][/url]", image, imgSize, image)) + if len(row) == len(sources) { + lines = append(lines, strings.Join(row, "")) + row = row[:0] + } + } + if len(row) > 0 { + lines = append(lines, strings.Join(row, "")) + } + return fmt.Sprintf( + "[spoiler=%s][center]%s[/center]\n%s[/spoiler]", + strings.Join(sources, " vs "), + strings.Join(sources, " | "), + strings.Join(lines, "\n"), + ) + }) +} + +func resolveACMTMDBTitle(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "" + } + return meta.ProviderMetadata.TMDB.Title +} + +func resolveACMTMDBOriginalTitle(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "" + } + return meta.ProviderMetadata.TMDB.OriginalTitle +} + +func resolveACMTMDBRetrievedAKA(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB == nil { + return "" + } + return meta.ProviderMetadata.TMDB.RetrievedAKA +} + +func resolveACMIMDBTitle(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB == nil { + return "" + } + return meta.ProviderMetadata.IMDB.Title +} + +func resolveACMIMDBAKA(meta api.UploadSubject) string { + if meta.ProviderMetadata.IMDB == nil { + return "" + } + return meta.ProviderMetadata.IMDB.AKA +} + +func baseName(meta api.UploadSubject) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} + +func numericValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + for _, r := range trimmed { + if r < '0' || r > '9' { + return "" + } + } + return trimmed +} diff --git a/internal/trackers/impl/unit3d/sites/acm/profile_test.go b/internal/trackers/impl/unit3d/sites/acm/profile_test.go new file mode 100644 index 00000000..b67498c9 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/acm/profile_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package acm + +import ( + "context" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestProfileParity(t *testing.T) { + profile := Profile().Site + if got := profile.ResolveTypeID(api.UploadSubject{ + DiscType: "BDMV", + UHD: "UHD", + SourceSize: 60 * (1 << 30), + }); got != "2" { + t.Fatalf("type = %q", got) + } + if got := profile.ResolveResolutionID(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "1080i"}}); got != "2" { + t.Fatalf("resolution = %q", got) + } + meta := api.UploadSubject{ + ReleaseName: "Example.2024.1080p.BluRay.REMUX.H.265.DD+ 5.1 Atmos-GRP", + Audio: "DD+ 5.1", + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{Title: "Example", OriginalTitle: "Original Example"}}, + SubtitleLanguages: []string{"Japanese"}, + } + name := profile.BuildName(meta, config.TrackerConfig{}) + if !strings.Contains(name, "Example / Original Example") || strings.Contains(name, "H.265") || !strings.Contains(name, "[Jpn subs only]") { + t.Fatalf("name = %q", name) + } + keywordsMeta := api.UploadSubject{ + Region: "3", + Distributor: "42", + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{Keywords: "one, two words, three,four,five,six,seven,eight,nine,ten,eleven,twelve"}}, + } + if got := profile.ResolveKeywords(keywordsMeta); got != "one, three, four, five, six, seven, eight, nine, ten, eleven" { + t.Fatalf("keywords = %q", got) + } + data := map[string]string{} + profile.ApplyAdditionalPayload(trackers.PreparationInput{Meta: keywordsMeta}, data) + if data["region_id"] != "3" || data["distributor_id"] != "42" { + t.Fatalf("payload = %#v", data) + } +} + +func TestDescriptionParity(t *testing.T) { + meta := api.UploadSubject{Type: "WEBDL", ServiceLongName: "Example Stream"} + result, err := Profile().Site.BuildDescription(context.Background(), meta, config.Config{}, config.TrackerConfig{}, api.NopLogger{}, "[pre]x[/pre]\n[hide=test]y[/hide]\n[img]https://img.example/z.png[/img]", nil, nil) + if err != nil { + t.Fatalf("description: %v", err) + } + for _, want := range []string{"[code]x[/code]", "[spoiler=test]y[/spoiler]", "not transcoded, just remuxed from the direct Example Stream stream", "[img=300]https://img.example/z.png[/img]"} { + if !strings.Contains(result, want) { + t.Fatalf("missing %q in %q", want, result) + } + } +} diff --git a/internal/trackers/impl/unit3d/sites/aither/api_doc.go b/internal/trackers/impl/unit3d/sites/aither/api_doc.go new file mode 100644 index 00000000..fec67ad8 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/aither/api_doc.go @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package aither + +// Aither API contract +// +// Aither is UNIT3D-based but publishes a site-specific API reference and adds +// behavior beyond the upstream UNIT3D baseline. This package owns Aither's +// deviations; shared request construction remains in the parent unit3d package. +// All requests use the configured AITHER base URL and Bearer API key. +// +// Upload +// +// POST /api/torrents/upload uses the shared multipart UNIT3D upload contract. +// Aither additionally receives these HDR classification fields: +// +// - dv: 1 when the release contains Dolby Vision; otherwise 0. +// - hdr10p: 1 when HDR10+ is present. +// - hdr: 1 for other HDR or HLG content. HDR10+ takes precedence, so hdr is +// not added by the Aither profile when hdr10p is set. +// +// The shared payload also carries deployment-supported fields such as stream, +// sd, pack, internalrip, keywords, and mod_queue_opt_in when applicable. Their +// inclusion is driven by prepared metadata and effective tracker configuration. +// +// Torrent lookup and duplicate search +// +// GET /api/torrents/:id fetches one torrent record. When no tracker ID is known, +// GET /api/torrents/filter supplies the standard UNIT3D filter fallback. +// Duplicate search uses tmdbId, categories[], resolutions[], optional types[], +// perPage=100, and an optional season token. Returned candidates are evaluated +// with Aither policy: trumpable IDs are tracked, DVD groups must match, SD may +// match HD, and 1080p size variance is permitted. +// +// Release-group blacklist +// +// GET /api/blacklists/releasegroups returns Aither's dynamic banned release +// groups. This endpoint is an Aither extension, not part of the standard UNIT3D +// torrent API. +// +// Request contract: +// +// - Method: GET. +// - Authentication: Authorization: Bearer . +// - Response format: application/json. +// - Query: per_page=100 on every request; cursor= after the first +// page. +// - Timeout: 20 seconds per HTTP request. +// +// Accepted response shapes are intentionally tolerant because deployed UNIT3D +// variants have returned several compatible representations. The decoder accepts +// a top-level array, a single group record, or a paginated object containing data +// and meta.next_cursor. Each data item may be a string or an object whose group +// name is stored under name, group, release_group, releaseGroup, or the same keys +// beneath a JSON:API attributes object. Blank or unrecognized records are ignored. +// Pagination stops when next_cursor is blank, rejects repeated cursors, and is +// capped at 1,000 pages. +// +// Successful bodies are bounded to 4 MiB and must contain exactly one JSON value. +// Non-2xx responses are reported by status only; response bodies are not copied +// into errors or logs. The normalized names and raw source records are persisted +// under the application's banned-group cache with mode 0600. A cache remains +// fresh for 24 hours. Refresh failure preserves the previous cache; cancellation +// is checked before fetching and before durable replacement. +// +// Aither registers this endpoint through BannedGroupPolicy with RequireAPIKey. +// Therefore a missing key skips remote refresh rather than making an anonymous +// request. The generic banned-group evaluator merges cached names with static +// tracker-owned groups and compares normalized release-group names. +// +// Internal-release claims +// +// GET /api/internals/claim returns active internal-release claims. This endpoint +// is also Aither-specific and is enabled by Aither's API-backed ClaimPolicy. +// +// Request contract: +// +// - Method: GET. +// - Authentication: Authorization: Bearer . +// - Response format: application/json. +// - Query: per_page=100 and optional cursor from the previous response. +// - Timeout: 20 seconds per HTTP request. +// +// The response is a JSON:API-style object. data contains records with an +// attributes object; meta.next_cursor selects the next page. Relevant attributes +// are title, season, tmdb_id, categories, resolutions, and types. season and +// tmdb_id may be JSON numbers or numeric strings. Records without a non-zero, +// parseable tmdb_id are discarded. Category, resolution, and type arrays are +// copied before caching so decoded response storage is not retained by callers. +// +// Claims are cached for 24 hours in the application's cache/banned directory as +// AITHER_claimed_releases.json with mode 0600. A fresh cache avoids network I/O. +// Missing base URL or API key produces no claims and no anonymous request. Any +// request construction, transport, non-2xx, or decode failure aborts claim +// evaluation and is returned with tracker context; remote response bodies are not +// included. +// +// Matching uses upload identity, not title alone. A claim requires the current +// release's TMDB ID, canonical Unit3D category, type, and resolution to match the +// claim. TV claims additionally require the canonical provider-resolved season; +// parsed filename season fallbacks are deliberately ignored. A semantic match +// adds the claim tracker block and claim_active rule failure before dupe checking +// or upload. A non-match leaves the release eligible. +// +// Naming and validation +// +// Aither naming inserts foreign-language markers for applicable non-English +// releases and applies DVD/DVD-rip normalization. Upload rules require a unique +// external ID and English audio or subtitles for non-disc releases, while +// allowing the original language. +// +// Site reference: https://aither.cc/pages/api#torrent-POSTapi-torrents-upload +// Upstream reference: https://github.com/HDInnovations/UNIT3D/blob/master/book/src/torrent_api.md diff --git a/internal/trackers/impl/unit3d/sites/aither/profile.go b/internal/trackers/impl/unit3d/sites/aither/profile.go new file mode 100644 index 00000000..64fea99a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/aither/profile.go @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package aither + +import ( + "strconv" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns AITHER's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "AITHER", + BaseURL: "https://aither.cc", + Rules: Rules(), + AudioPolicy: AudioPolicy(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ApplyAdditionalPayload: additionalPayload, + }, + DupePolicy: &trackers.DupePolicy{ + TrackTrumpableID: true, + MatchDVDReleaseGroup: true, + SDMatchesHD: true, + AllowSizeVariance1080: true, + }, + BannedPolicy: &trackers.BannedGroupPolicy{ + EndpointPath: "/api/blacklists/releasegroups", + RequireAPIKey: true, + }, + ClaimPolicy: &trackers.ClaimPolicy{ + APIBacked: true, + }, + } +} + +func additionalPayload(req trackers.PreparationInput, data map[string]string) { + hdr := strings.ToUpper(strings.TrimSpace(req.Meta.HDR)) + data["dv"] = boolString(strings.Contains(hdr, "DV")) + if strings.Contains(hdr, "HDR10+") { + data["hdr10p"] = "1" + return + } + if strings.Contains(hdr, "HDR") || strings.Contains(hdr, "HLG") { + data["hdr"] = "1" + } +} + +func boolString(value bool) string { + if value { + return "1" + } + return "0" +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + if name == "" { + return "" + } + resolution, videoCodec, videoEncode := unit3d.Resolution(meta), strings.TrimSpace(meta.VideoCodec), strings.TrimSpace(meta.VideoEncode) + nameType, source, audio := strings.ToUpper(strings.TrimSpace(meta.Type)), strings.TrimSpace(meta.Source), strings.TrimSpace(meta.Audio) + languages := append([]string{}, meta.Release.Language...) + if len(languages) > 0 && !unit3d.HasEnglishLanguage(languages) { + foreign := strings.ToUpper(strings.TrimSpace(languages[0])) + if nameType == "REMUX" && isDVDSource(source) && meta.Release.Year > 0 { + year := strconv.Itoa(meta.Release.Year) + name = strings.Replace(name, year, year+" "+foreign, 1) + } else if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") && resolution != "" { + name = strings.Replace(name, resolution, foreign+" "+resolution, 1) + } + } + if nameType == "DVDRIP" { + source = "DVDRip" + if meta.Source != "" { + name = strings.Replace(name, meta.Source+" ", "", 1) + } + if videoEncode != "" { + name = strings.Replace(name, videoEncode, "", 1) + } + if resolution != "" { + name = strings.Replace(name, source, resolution+" "+source, 1) + } + if audio != "" && videoEncode != "" { + name = strings.Replace(name, audio, audio+videoEncode, 1) + } + } else if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || (nameType == "REMUX" && isDVDSource(source)) { + if resolution != "" && source != "" { + name = strings.Replace(name, source, resolution+" "+source, 1) + } + if audio != "" && videoCodec != "" { + name = strings.Replace(name, audio, videoCodec+" "+audio, 1) + } + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} + +func isDVDSource(source string) bool { + switch strings.ToUpper(strings.TrimSpace(source)) { + case "PAL DVD", "NTSC DVD", "DVD": + return true + default: + return false + } +} diff --git a/internal/trackers/impl/unit3d/sites/aither/rules.go b/internal/trackers/impl/unit3d/sites/aither/rules.go new file mode 100644 index 00000000..c7988bbb --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/aither/rules.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package aither + +import ( + "github.com/autobrr/upbrr/internal/trackers" +) + +// Rules returns AITHER's strict unique-ID requirement and strict non-disc +// English audio-or-subtitle requirement, accepting original-language audio +// when English subtitles are present. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{RequireUniqueID: true, Language: englishNonDisc()} +} + +// AudioPolicy allows English as an additional audio language. +func AudioPolicy() *trackers.AudioPolicy { + return &trackers.AudioPolicy{AllowedLanguages: []string{"english"}} +} +func englishNonDisc() *trackers.LanguageRule { + return &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonDisc: true, + } +} diff --git a/internal/trackers/impl/unit3d/sites/blu/banned_groups.go b/internal/trackers/impl/unit3d/sites/blu/banned_groups.go new file mode 100644 index 00000000..358a1e48 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/blu/banned_groups.go @@ -0,0 +1,97 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package blu + +// BannedGroups returns BLU's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "[Oj]", + "3LTON", + "4yEo", + "ADE", + "AFG", + "AniHLS", + "AnimeRG", + "AniURL", + "AROMA", + "aXXo", + "B3LLUM", + "BHDStudio", + "Brrip", + "CHD", + "CM8", + "CrEwSaDe", + "d3g", + "DeadFish", + "DNL", + "DTLegacy", + "ELiTE", + "eSc", + "EZTV", + "EZTV.RE", + "F13", + "FaNGDiNG0", + "FGT", + "Flights", + "flower", + "FRDS", + "FUM", + "HAiKU", + "hallowed", + "HD2DVD", + "HDS", + "HDTime", + "Hi10", + "ION10", + "iPlanet", + "JIVE", + "KiNGDOM", + "LAMA", + "Leffe", + "LEGi0N", + "LOAD", + "MeGusta", + "mHD", + "mSD", + "NhaNc3", + "nHD", + "nikt0", + "NOIVTC", + "nSD", + "OFT", + "PiRaTeS", + "playBD", + "PlaySD", + "playXD", + "PRODJi", + "RAPiDCOWS", + "RARBG", + "RetroPeeps", + "RDN", + "REsuRRecTioN", + "RMTeam", + "SANTi", + "SasukeducK", + "SicFoI", + "SPASM", + "SPDVD", + "STUTTERSHIT", + "Telly", + "TheFarm", + "TM", + "TRiToN", + "UPiNSMOKE", + "URANiME", + "VN_Foxcore", + "WAF", + "WKS", + "x0r", + "xRed", + "XS", + "YIFY", + "ZKBL", + "ZmN", + "ZMNT", + } +} diff --git a/internal/trackers/impl/unit3d/sites/blu/profile.go b/internal/trackers/impl/unit3d/sites/blu/profile.go new file mode 100644 index 00000000..9082294b --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/blu/profile.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package blu + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns BLU's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "BLU", + BaseURL: "https://blutopia.cc", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + ResolveCategoryID: categoryID, + }, + } +} + +func categoryID(meta api.UploadSubject) string { + if strings.Contains(strings.ToUpper(strings.TrimSpace(meta.Edition)), "FANRES") { + return "3" + } + return unit3d.DefaultCategoryID(meta) +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "1", + "REMUX": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "ENCODE": "12", + }[unit3d.InferType(meta)] +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "8640p": "10", + "4320p": "11", + "2160p": "1", + "1440p": "2", + "1080p": "2", + "1080i": "3", + "720p": "5", + "576p": "6", + "576i": "7", + "480p": "8", + "480i": "9", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/blu/rules.go b/internal/trackers/impl/unit3d/sites/blu/rules.go new file mode 100644 index 00000000..f703a760 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/blu/rules.go @@ -0,0 +1,47 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package blu + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules strictly limits non-disc uploads to MKV, with TS allowed for HDTV and +// MP4 allowed for Dolby Vision-only WEBDL or HDTV releases. +func Rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkContainer} } + +func checkContainer(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + if unit3d.IsDiscType(meta.DiscType) { + return nil, nil + } + container := strings.ToLower(strings.TrimSpace(meta.Container)) + if container == "" { + return nil, nil + } + allowed := []string{"mkv"} + typeValue := unit3d.RuleType(meta) + if typeValue == "HDTV" { + allowed = append(allowed, "ts") + } + if (typeValue == "WEBDL" || typeValue == "HDTV") && unit3d.DolbyVisionOnly(meta) { + allowed = append(allowed, "mp4") + } + if unit3d.ContainsRuleValue([]string{container}, allowed) { + return nil, nil + } + return []api.RuleFailure{trackers.NewRuleFailure( + "container", + "BLU requires one of the following containers for this release: "+strings.ToUpper(strings.Join(allowed, ", ")), + api.RuleDispositionStrict, + )}, nil +} diff --git a/internal/trackers/impl/unit3d/sites/cbr/banned_groups.go b/internal/trackers/impl/unit3d/sites/cbr/banned_groups.go new file mode 100644 index 00000000..31c92907 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/cbr/banned_groups.go @@ -0,0 +1,90 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package cbr + +// BannedGroups returns CBR's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "4K4U", + "afm72", + "Alcaide_Kira", + "AROMA", + "ASM", + "Bandi", + "BiTOR", + "BLUDV", + "Bluespots", + "BOLS", + "CaNNIBal", + "Comando", //nolint:misspell // Specific group spelling. + "d3g", + "DepraveD", + "EMBER", + "FGT", + "FreetheFish", + "Garshasp", + "Ghost", + "Grym", + "HDS", + "Hi10", + "HiQVE", + "Hiro360", + "ImE", + "ION10", + "iVy", + "Judas", + "LAMA", + "Langbard", + "Lapumia", + "LION", + "MeGusta", + "MONOLITH", + "MRCS", + "NaNi", + "Natty", + "nikt0", + "OEPlus", + "OFT", + "OsC", + "Panda", + "PANDEMONiUM", + "PHOCiS", + "PiRaTeS", + "PYC", + "QxR", + "r00t", + "Ralphy", + "RARBG", + "RetroPeeps", + "RZeroX", + "S74Ll10n", + "SAMPA", + "Sicario", + "SiCFoI", + "Silence", + "SkipTT", + "SM737", + "SPDVD", + "STUTTERSHIT", + "SWTYBLZ", + "t3nzin", + "TAoE", + "TEKNO3D", + "Telly", + "TGx", + "Tigole", + "TSP", + "TSPxL", + "TWA", + "UnKn0wn", + "VXT", + "Vyndros", + "W32", + "Will1869", + "x0r", + "YIFY", + "YTS.MX", + "YTS", + } +} diff --git a/internal/trackers/impl/unit3d/sites/cbr/profile.go b/internal/trackers/impl/unit3d/sites/cbr/profile.go new file mode 100644 index 00000000..feafc8da --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/cbr/profile.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package cbr + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns CBR's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "CBR", + BaseURL: "https://capybarabr.com", + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveCategoryID: categoryID, + }, + } +} +func buildName(meta api.UploadSubject, cfg config.TrackerConfig) string { + return unit3d.FormatLocalizedName(meta, cfg.TagForCustomRelease) +} +func categoryID(meta api.UploadSubject) string { + if strings.EqualFold(unit3d.Category(meta), "TV") && meta.Anime { + return "4" + } + return unit3d.DefaultCategoryID(meta) +} diff --git a/internal/trackers/impl/unit3d/sites/dp/banned_groups.go b/internal/trackers/impl/unit3d/sites/dp/banned_groups.go new file mode 100644 index 00000000..dc87af4d --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/dp/banned_groups.go @@ -0,0 +1,72 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dp + +// BannedGroups returns DP's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "ARCADE", + "aXXo", + "BANDOLEROS", + "BONE", + "BRrip", + "CM8", + "CrEwSaDe", + "CTFOH", + "dAV1nci", + "DNL", + "eranger2", + "FaNGDiNG0", + "FGT", + "FiSTER", + "flower", + "GalaxyTV", + "HD2DVD", + "HDTime", + "HorribleSubs", + "iHYTECH", + "ION10", + "iPlanet", + "KiNGDOM", + "LAMA", + "MeGusta", + "mHD", + "mSD", + "NaNi", + "NhaNc3", + "nHD", + "nikt0", + "nSD", + "OFT", + "PiTBULL", + "PRODJi", + "PSA", + "RARBG", + "Rifftrax", + "ROCKETRACCOON", + "SANTi", + "SasukeducK", + "SEEDSTER", + "ShAaNiG", + "Sicario", + "STUTTERSHIT", + "Subsplease", + "SyncUp", + "TAoE", + "TGALAXY", + "TGx", + "TORRENTGALAXY", + "ToVaR", + "Trix", + "TSP", + "TSPxL", + "ViSION", + "VXT", + "WAF", + "WKS", + "X0r", + "YIFY", + "YTS", + } +} diff --git a/internal/trackers/impl/unit3d/sites/dp/profile.go b/internal/trackers/impl/unit3d/sites/dp/profile.go new file mode 100644 index 00000000..de0ae67d --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/dp/profile.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dp + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns DP's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "DP", + BaseURL: "https://darkpeers.org", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + }, + } +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := baseName(meta) + if label := audioLabel(meta.AudioLanguages); label != "" { + name = strings.Replace(name, "Dual-Audio", label, 1) + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} +func baseName(meta api.UploadSubject) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} +func audioLabel(values []string) string { + unique := map[string]struct{}{} + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + unique[strings.ToUpper(value)] = struct{}{} + } + } + switch len(unique) { + case 0: + return "" + case 1: + for value := range unique { + return value + } + return "" + case 2: + return "Dual-Audio" + default: + return "MULTi" + } +} diff --git a/internal/trackers/impl/unit3d/sites/dp/rules.go b/internal/trackers/impl/unit3d/sites/dp/rules.go new file mode 100644 index 00000000..8d6691a7 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/dp/rules.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package dp + +import "github.com/autobrr/upbrr/internal/trackers" + +var nordic = []string{"english", "norwegian", "norsk", "no", "nb", "nn", "swedish", "sv", "danish", "da", "finnish", "fi", "icelandic", "is"} + +// Rules strictly blocks single-file folders and hardcoded subtitles. Its +// release-group exception remains waivable; its Nordic language requirement is +// strict. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockSingleFileFolder: true, + BlockHardcodedSubs: true, + BlockGroupUnlessType: map[string][]string{"EVO": {"WEBDL"}}, + Language: &trackers.LanguageRule{ + Languages: nordic, + RequireAudio: true, + RequireSubs: true, + }, + } +} diff --git a/internal/trackers/impl/unit3d/sites/emuw/profile.go b/internal/trackers/impl/unit3d/sites/emuw/profile.go new file mode 100644 index 00000000..243f31de --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/emuw/profile.go @@ -0,0 +1,50 @@ +package emuw + +import ( + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns EMUW's type and resolution mappings. Unknown types use the +// encode ID, while unknown resolutions use the site's Other ID. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "EMUW", + BaseURL: "https://emuwarez.com", + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + }, + } +} + +func typeID(meta api.UploadSubject) string { + value := map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + }[unit3d.InferType(meta)] + if value == "" { + return "3" + } + return value +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + "720p": "5", + "576p": "6", + "540p": "7", + "480p": "8", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/friki/profile.go b/internal/trackers/impl/unit3d/sites/friki/profile.go new file mode 100644 index 00000000..06abb76c --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/friki/profile.go @@ -0,0 +1,14 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package friki + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns FRIKI's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "FRIKI", + BaseURL: "https://frikibar.com", + } +} diff --git a/internal/trackers/impl/unit3d/sites/hhd/banned_groups.go b/internal/trackers/impl/unit3d/sites/hhd/banned_groups.go new file mode 100644 index 00000000..1d9bd550 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/hhd/banned_groups.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hhd + +// BannedGroups returns HHD's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "aXXo", + "BONE", + "BRrip", + "CM8", + "CrEwSaDe", + "CTFOH", + "dAV1nci", + "d3g", + "DNL", + "FaNGDiNG0", + "GalaxyTV", + "HD2DVD", + "HDTime", + "iHYTECH", + "ION10", + "iPlanet", + "KiNGDOM", + "LAMA", + "MeGusta", + "mHD", + "mSD", + "NaNi", + "NhaNc3", + "nHD", + "nikt0", + "nSD", + "OFT", + "PRODJi", + "RARBG", + "Rifftrax", + "SANTi", + "SasukeducK", + "ShAaNiG", + "Sicario", + "STUTTERSHIT", + "TGALAXY", + "TORRENTGALAXY", + "TSP", + "TSPxL", + "ViSION", + "VXT", + "WAF", + "WKS", + "x0r", + "YAWNiX", + "YIFY", + "YTS", + "PSA", + "EVO", + } +} diff --git a/internal/trackers/impl/unit3d/sites/hhd/profile.go b/internal/trackers/impl/unit3d/sites/hhd/profile.go new file mode 100644 index 00000000..700f032a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/hhd/profile.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hhd + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns HHD's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "HHD", + BaseURL: "https://homiehelpdesk.net", + Rules: Rules(), + BannedGroups: BannedGroups(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/hhd/rules.go b/internal/trackers/impl/unit3d/sites/hhd/rules.go new file mode 100644 index 00000000..8bf0fc05 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/hhd/rules.go @@ -0,0 +1,9 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package hhd + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly blocks DVDRip uploads. +func Rules() *trackers.RuleSet { return &trackers.RuleSet{BlockDVDRip: true} } diff --git a/internal/trackers/impl/unit3d/sites/ihd/profile.go b/internal/trackers/impl/unit3d/sites/ihd/profile.go new file mode 100644 index 00000000..eed82072 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ihd/profile.go @@ -0,0 +1,47 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ihd + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns IHD's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "IHD", + BaseURL: "https://infinityhd.net", + Site: unit3d.SiteProfile{ + ResolveResolutionID: resolutionID, + ResolveCategoryID: categoryID, + }, + } +} + +func categoryID(meta api.UploadSubject) string { + category := unit3d.Category(meta) + if strings.EqualFold(category, "TV") && meta.Anime { + return "3" + } + if strings.EqualFold(category, "MOVIE") && meta.Anime { + return "4" + } + return unit3d.DefaultCategoryID(meta) +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1440p": "3", + "1080p": "3", + "1080i": "4", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/itt/profile.go b/internal/trackers/impl/unit3d/sites/itt/profile.go new file mode 100644 index 00000000..6d63d987 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/itt/profile.go @@ -0,0 +1,45 @@ +package itt + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns ITT's type mapping, preferring recognized release-name type +// markers before falling back to the inferred Unit3D type. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "ITT", + BaseURL: "https://itatorrents.xyz", + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + }, + } +} + +func typeID(meta api.UploadSubject) string { + name := strings.ToUpper(strings.TrimSpace(meta.ReleaseName)) + for marker, id := range map[string]string{ + "DLMUX": "27", + "BDMUX": "29", + "WEBMUX": "26", + "DVDMUX": "39", + "BDRIP": "25", + } { + if strings.Contains(name, marker) { + return id + } + } + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "ENCODE": "3", + "DVDRIP": "24", + "CINEMA-MD": "14", + }[unit3d.InferType(meta)] +} diff --git a/internal/trackers/impl/unit3d/sites/lcd/profile.go b/internal/trackers/impl/unit3d/sites/lcd/profile.go new file mode 100644 index 00000000..5abeec26 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lcd/profile.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lcd + +import ( + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns LCD's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "LCD", + BaseURL: "https://locadora.cc", + Site: unit3d.SiteProfile{ + BuildName: buildName, + }, + } +} +func buildName(meta api.UploadSubject, cfg config.TrackerConfig) string { + return unit3d.FormatLocalizedName(meta, cfg.TagForCustomRelease) +} diff --git a/internal/trackers/impl/unit3d/sites/ldu/profile.go b/internal/trackers/impl/unit3d/sites/ldu/profile.go new file mode 100644 index 00000000..23bde6b1 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ldu/profile.go @@ -0,0 +1,155 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ldu + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns LDU's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "LDU", + BaseURL: "https://theldu.to", + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveCategoryID: categoryID, + }, + } +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + nonEnglishOriginal := !isEnglish(originalLanguage(meta)) + audio, nonEnglishAudio := firstAudio(meta.AudioLanguages) + subtitle := firstSubtitle(meta.SubtitleLanguages) + if categoryID(meta) == "18" && subtitle != "" { + return clean(name + " [Subs " + subtitle + "]") + } + if !nonEnglishOriginal && !nonEnglishAudio { + return clean(name) + } + parts := make([]string, 0, 2) + if audio != "" { + parts = append(parts, "["+audio+"]") + } + if subtitle != "" { + parts = append(parts, "[Subs "+subtitle+"]") + } + if len(parts) == 0 { + return clean(name) + } + return clean(name + " " + strings.Join(parts, " ")) +} +func clean(value string) string { return strings.TrimSpace(strings.Join(strings.Fields(value), " ")) } +func firstAudio(values []string) (string, bool) { + for _, value := range values { + if code, english, ok := languageCode(value); ok { + return code, !english + } + } + return "", false +} +func firstSubtitle(values []string) string { + for _, value := range values { + if code, _, ok := languageCode(value); ok { + return code + } + } + return "" +} +func languageCode(value string) (string, bool, bool) { + tag, ok := unit3d.ParseLanguageTag(value) + if !ok { + return "", false, false + } + base, _ := tag.Base() + if base.String() == "und" { + return "", false, false + } + code := base.ISO3() + if code == "" { + return "", false, false + } + return strings.ToUpper(code), base.String() == "en", true +} +func originalLanguage(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil && strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) != "" { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) + } + if meta.ProviderMetadata.IMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) + } + return "" +} +func isEnglish(value string) bool { + tag, ok := unit3d.ParseLanguageTag(value) + if !ok { + return false + } + base, _ := tag.Base() + return base.String() == "en" +} + +func categoryID(meta api.UploadSubject) string { + category := unit3d.Category(meta) + genres := strings.ToLower( + strings.TrimSpace( + strings.Join([]string{strings.TrimSpace(meta.Release.Genre), unit3d.Keywords(meta), unit3d.TMDBGenres(meta), unit3d.IMDBGenres(meta)}, ","), + ), + ) + hasEnglishAudio := unit3d.HasEnglishLanguage(meta.AudioLanguages) + hasEnglishSubs := unit3d.HasEnglishLanguage(meta.SubtitleLanguages) + containsDubbed := strings.Contains(strings.ToLower(strings.TrimSpace(meta.Audio)), "dubbed") + edition := strings.ToLower(strings.TrimSpace(meta.Edition)) + + if strings.EqualFold(category, "MOVIE") { + switch { + case meta.Anime || meta.Identity.MALID != 0: + return "8" + case strings.Contains(edition, "fanedit") || strings.Contains(edition, "fanres"): + return "12" + case strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D"): + return "21" + case unit3d.HasAdultToken(genres) && !hasEnglishAudio && !hasEnglishSubs: + return "45" + case unit3d.HasAdultToken(genres): + return "6" + case strings.Contains(genres, "documentary"): + return "17" + case strings.Contains(genres, "musical"): + return "25" + case !hasEnglishAudio && !hasEnglishSubs: + return "22" + case containsDubbed: + return "27" + default: + return "1" + } + } + if strings.EqualFold(category, "TV") { + switch { + case meta.Anime || meta.Identity.MALID != 0: + return "9" + case strings.Contains(genres, "documentary"): + return "40" + case !hasEnglishAudio && !hasEnglishSubs: + return "29" + case meta.TVPack: + return "2" + case containsDubbed: + return "31" + default: + return "41" + } + } + return unit3d.DefaultCategoryID(meta) +} diff --git a/internal/trackers/impl/unit3d/sites/ldu/profile_test.go b/internal/trackers/impl/unit3d/sites/ldu/profile_test.go new file mode 100644 index 00000000..069d8dc7 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ldu/profile_test.go @@ -0,0 +1,23 @@ +package ldu + +import ( + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildNameUsesFirstParseableLanguages(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Example.Release.2026.1080p.WEB-DL.DD5.1.H264-GRP", + Identity: api.ExternalIdentity{Category: "MOVIE"}, + AudioLanguages: []string{"", "Japanese", "English"}, + SubtitleLanguages: []string{"", "English"}, + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}}, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + if !strings.Contains(got, "[JPN]") || !strings.Contains(got, "[Subs ENG]") { + t.Fatalf("name = %q", got) + } +} diff --git a/internal/trackers/impl/unit3d/sites/lst/profile.go b/internal/trackers/impl/unit3d/sites/lst/profile.go new file mode 100644 index 00000000..cfbeef25 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lst/profile.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lst + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" +) + +// Profile returns LST's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "LST", + BaseURL: "https://lst.gg", + Rules: Rules(), + Site: unit3d.SiteProfile{ + ApplyAdditionalPayload: additionalPayload, + }, + DupePolicy: &trackers.DupePolicy{ + TrackTrumpableID: true, + MatchDVDReleaseGroup: true, + }, + BannedPolicy: &trackers.BannedGroupPolicy{ + EndpointPath: "/api/bannedReleaseGroups", + RequireAPIKey: true, + }, + ImageHost: &trackers.ImageHostPolicy{ + ConditionalHost: "lostimg", + OwnedHosts: []string{"lostimg"}, + EnableWithLostimg: true, + }, + } +} + +func additionalPayload(req trackers.PreparationInput, data map[string]string) { + if req.TrackerConfig.Draft { + data["draft_queue_opt_in"] = "1" + } else { + data["draft_queue_opt_in"] = "0" + } + if editionID, ok := editionID(req.Meta.Edition); ok { + data["edition_id"] = editionID + } +} + +func editionID(edition string) (string, bool) { + normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(edition)), "’", "'") + value, ok := map[string]string{ + "collector's edition": "1", + "director's cut": "2", + "extended cut": "3", + "extended uncut": "4", + "extended unrated": "5", + "limited edition": "6", + "special edition": "7", + "theatrical cut": "8", + "uncut": "9", + "unrated": "10", + "x cut": "11", + "alternative cut": "12", + "other": "0", + }[normalized] + return value, ok +} diff --git a/internal/trackers/impl/unit3d/sites/lst/rules.go b/internal/trackers/impl/unit3d/sites/lst/rules.go new file mode 100644 index 00000000..f12c321a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lst/rules.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lst + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly requires valid MediaInfo encode settings and applies a +// strict non-disc English audio-or-subtitle requirement. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{RequireValidMISetting: true, Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonDisc: true, + }} +} diff --git a/internal/trackers/impl/unit3d/sites/lt/banned_groups.go b/internal/trackers/impl/unit3d/sites/lt/banned_groups.go new file mode 100644 index 00000000..3f5d4afd --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lt/banned_groups.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lt + +// BannedGroups returns LT's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "EVO", + } +} diff --git a/internal/trackers/impl/unit3d/sites/lt/name.go b/internal/trackers/impl/unit3d/sites/lt/name.go new file mode 100644 index 00000000..0b449c73 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lt/name.go @@ -0,0 +1,260 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lt + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + markerPattern = regexp.MustCompile(`\b(?:1080p|1080i|720p|2160p|4320p|8640p|mkv|mp4|avi|ts|bluray|blu-ray|web-dl|webdl|webrip|hdtv|dvd|complete|vostfr|multi|subfrench|french|truefrench)\b`) + akaPattern = regexp.MustCompile(`(?i)\baka\b`) + multipleDotsPattern = regexp.MustCompile(`\.+`) + multipleSpacesPattern = regexp.MustCompile(`\s+`) + trailingSuffixPattern = regexp.MustCompile(`(?i)\b(?:s\d+e\d+|s\d+|e\d+|\d{4})\b.*$`) +) + +var audioLatinoCheck = map[string]bool{ + "lat": true, "es-419": true, "es-mx": true, +} + +var audioCastilianCheck = map[string]bool{ + "castilian": true, "es-es": true, +} + +var latinoKeywords = []string{ + "latino", "lat", "es-419", "mexicano", "argentino", "chileno", "colombiano", "peruano", "venezolano", +} + +var castilianKeywords = []string{ + "castellano", "castilian", "es-es", "español de españa", "espanol de espana", +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + aka := "" + title := "" + origTitle := "" + origLang := "" + if meta.ProviderMetadata.TMDB != nil { + aka = meta.ProviderMetadata.TMDB.RetrievedAKA + title = meta.ProviderMetadata.TMDB.Title + origTitle = meta.ProviderMetadata.TMDB.OriginalTitle + origLang = meta.ProviderMetadata.TMDB.OriginalLanguage + } + if title == "" && meta.ProviderMetadata.IMDB != nil { + title = meta.ProviderMetadata.IMDB.Title + } + if origTitle == "" { + origTitle = title + } + + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + + // 1. Strip episode title for LT + category := unit3d.Category(meta) + if category == "TV" && meta.EpisodeTitle != "" { + name = removeEpisodeTitle(name, meta.EpisodeTitle) + } + + ltName := name + ltName = strings.ReplaceAll(ltName, "Dual-Audio", "") + ltName = strings.ReplaceAll(ltName, "Dubbed", "") + + // Find the end of the title block (start of year, resolution, source, etc.) + titleEndIdx := len(ltName) + if loc := markerPattern.FindStringIndex(strings.ToLower(ltName)); loc != nil { + titleEndIdx = loc[0] + } + + titleBlock := ltName[:titleEndIdx] + restOfName := ltName[titleEndIdx:] + + // Preserve trailing season/episode/year in titleBlock + cleanTitleBlock := titleBlock + var trailingSuffix string + if loc := trailingSuffixPattern.FindStringIndex(titleBlock); loc != nil { + trailingSuffix = titleBlock[loc[0]:] + cleanTitleBlock = titleBlock[:loc[0]] + } + + // Determine the correct target title to use + targetTitle := title + if origLang == "es" { + // Use Spanish title for Spanish original series/movies + if aka != "" { + akaClean := strings.TrimSpace(strings.ReplaceAll(aka, "AKA", "")) + if akaClean != "" { + targetTitle = akaClean + } + } else if origTitle != "" { + targetTitle = origTitle + } + } + + if targetTitle != "" { + targetTitleDotted := strings.ReplaceAll(targetTitle, " ", ".") + cleanTitleBlockNorm := strings.Trim(strings.ReplaceAll(cleanTitleBlock, ".", " "), " ") + // If the title block contains AKA, or we need to replace a mismatched title: + if akaPattern.MatchString(cleanTitleBlock) || !strings.EqualFold(cleanTitleBlockNorm, targetTitle) { + cleanTitleBlock = targetTitleDotted + } + } + + titleBlock = cleanTitleBlock + "." + trailingSuffix + ltName = titleBlock + restOfName + + isDisc := false + if unit3d.IsDiscType(meta.DiscType) || unit3d.IsDiscType(meta.Release.Type) || meta.DiscType != "" { + isDisc = true + } + + if !isDisc { + type rawMediaInfoDoc struct { + Media struct { + Track []map[string]any `json:"track"` + } `json:"media"` + } + + var tracks []map[string]any + if meta.MediaInfoJSONPath != "" { + if payload, err := os.ReadFile(meta.MediaInfoJSONPath); err == nil { + var doc rawMediaInfoDoc + if err := json.Unmarshal(payload, &doc); err == nil { + tracks = doc.Media.Track + } + } + } + + hasSpanishAudio := false + hasLatino := false + hasCastilian := false + + hasTracks := false + if len(tracks) > 0 { + for _, track := range tracks { + trackType := "" + if val, ok := track["@type"]; ok { + trackType = strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", val))) + } + if trackType != "audio" { + continue + } + hasTracks = true + lang := strings.ToLower(namesTrackString(track, "Language", "Language_String", "Language_String2", "Language_String3")) + titleText := strings.ToLower(namesTrackString(track, "Title", "Title_String", "Title_String2", "Title_String3")) + + if strings.Contains(titleText, "commentary") { + continue + } + + isLatinoTitle := false + for _, kw := range latinoKeywords { + if strings.Contains(titleText, kw) { + isLatinoTitle = true + break + } + } + isCastilianTitle := false + for _, kw := range castilianKeywords { + if strings.Contains(titleText, kw) { + isCastilianTitle = true + break + } + } + + if audioLatinoCheck[lang] || (lang == "es" && isLatinoTitle) { + hasLatino = true + hasSpanishAudio = true + } else if (lang == "es" && isCastilianTitle) || audioCastilianCheck[lang] { + hasCastilian = true + hasSpanishAudio = true + } + } + } + + if !hasTracks { + // Fallback to AudioLanguages if MediaInfo JSON wasn't parsed + for _, lang := range meta.AudioLanguages { + if strings.EqualFold(lang, "Spanish") || strings.EqualFold(lang, "es") { + hasSpanishAudio = true + hasLatino = true + break + } + } + } + + tag := strings.TrimSpace(meta.Tag) + // insertTagBracket inserts the label just before "-" so the result + // is "… [LABEL]-TAG" rather than "…- [LABEL]TAG". + insertTagBracket := func(s, label string) string { + if tag != "" { + sep := "-" + tag + if idx := strings.LastIndex(s, sep); idx != -1 { + return s[:idx] + " [" + label + "]" + s[idx:] + } + } + return s + " [" + label + "]" + } + if hasSpanishAudio { + if !hasLatino && hasCastilian { + if !strings.Contains(ltName, "[CAST]") { + ltName = insertTagBracket(ltName, "CAST") + } + } + } else { + if !strings.Contains(ltName, "[SUBS]") { + ltName = insertTagBracket(ltName, "SUBS") + } + } + } + + ltName = multipleDotsPattern.ReplaceAllString(ltName, ".") + ltName = multipleSpacesPattern.ReplaceAllString(ltName, " ") + return strings.Trim(ltName, ". ") +} + +func removeEpisodeTitle(name string, episodeTitle string) string { + cleanedTitle := strings.TrimSpace(episodeTitle) + if cleanedTitle == "" { + return name + } + + // Remove with dots + dotted := strings.ReplaceAll(cleanedTitle, " ", ".") + name = strings.ReplaceAll(name, dotted, "") + + // Remove with spaces + name = strings.ReplaceAll(name, cleanedTitle, "") + + // Clean up any duplicates + name = strings.ReplaceAll(name, "..", ".") + name = strings.ReplaceAll(name, " ", " ") + name = strings.ReplaceAll(name, " .", ".") + name = strings.ReplaceAll(name, ". ", ".") + + return strings.Trim(name, ". ") +} + +func namesTrackString(track map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := track[key]; ok { + trimmed := strings.TrimSpace(fmt.Sprintf("%v", value)) + if trimmed != "" { + return trimmed + } + } + } + return "" +} diff --git a/internal/trackers/impl/unit3d/sites/lt/profile.go b/internal/trackers/impl/unit3d/sites/lt/profile.go new file mode 100644 index 00000000..69f68777 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lt/profile.go @@ -0,0 +1,85 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lt + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns LT's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "LT", + BaseURL: "https://lat-team.com", + BannedGroups: BannedGroups(), + Rules: Rules(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveCategoryID: resolveCategoryID, + }, + } +} + +var ltAsianCountries = map[string]bool{ + "AE": true, "AF": true, "AM": true, "AZ": true, "BD": true, "BH": true, "BN": true, "BT": true, "CN": true, "CY": true, "GE": true, "HK": true, "ID": true, "IL": true, "IN": true, + "IQ": true, "IR": true, "JO": true, "JP": true, "KG": true, "KH": true, "KP": true, "KR": true, "KW": true, "KZ": true, "LA": true, "LB": true, "LK": true, "MM": true, "MN": true, + "MO": true, "MV": true, "MY": true, "NP": true, "OM": true, "PH": true, "PK": true, "PS": true, "QA": true, "SA": true, "SG": true, "SY": true, "TH": true, "TJ": true, "TL": true, + "TM": true, "TR": true, "TW": true, "UZ": true, "VN": true, "YE": true, +} + +func resolveCategoryID(meta api.UploadSubject) string { + category := unit3d.Category(meta) + + categoryID := "1" // Default MOVIE + if category == "TV" { + categoryID = "2" // Default TV + } + + if category == "TV" { + if meta.Anime { + return "5" + } + + keywords := "" + overview := "" + genres := "" + var originCountries []string + + if meta.ProviderMetadata.TMDB != nil { + keywords = strings.ToLower(meta.ProviderMetadata.TMDB.Keywords) + overview = strings.ToLower(meta.ProviderMetadata.TMDB.Overview) + genres = strings.ToLower(meta.ProviderMetadata.TMDB.Genres) + originCountries = meta.ProviderMetadata.TMDB.OriginCountry + } + + soapKeywords := []string{"telenovela", "novela", "soap", "culebrón", "culebron"} + hasSoap := false + for _, kw := range soapKeywords { + if strings.Contains(keywords, kw) || strings.Contains(overview, kw) { + hasSoap = true + break + } + } + if hasSoap { + return "8" + } + + hasAsianCountry := false + for _, c := range originCountries { + if ltAsianCountries[strings.ToUpper(c)] { + hasAsianCountry = true + break + } + } + + if strings.Contains(genres, "drama") && hasAsianCountry { + return "20" + } + } + + return categoryID +} diff --git a/internal/trackers/impl/unit3d/sites/lt/profile_test.go b/internal/trackers/impl/unit3d/sites/lt/profile_test.go new file mode 100644 index 00000000..a7fe302d --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lt/profile_test.go @@ -0,0 +1,172 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lt + +import ( + "context" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestProfileCategoryID(t *testing.T) { + profile := Profile().Site + + // Movie + if got := profile.ResolveCategoryID(api.UploadSubject{Type: "WEBDL", Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}}); got != "1" { + t.Fatalf("expected Movie category ID 1, got %q", got) + } + + // TV + if got := profile.ResolveCategoryID(api.UploadSubject{Type: "WEBDL", Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}}); got != "2" { + t.Fatalf("expected TV category ID 2, got %q", got) + } + + // Anime + if got := profile.ResolveCategoryID(api.UploadSubject{Type: "WEBDL", Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}, Anime: true}); got != "5" { + t.Fatalf("expected Anime category ID 5, got %q", got) + } + + // Soap/Telenovela + soapMeta := api.UploadSubject{ + Type: "WEBDL", + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Keywords: "telenovela", + }, + }, + } + if got := profile.ResolveCategoryID(soapMeta); got != "8" { + t.Fatalf("expected Soap category ID 8, got %q", got) + } + + // Turkish/Asian drama + dramaMeta := api.UploadSubject{ + Type: "WEBDL", + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Genres: "drama", + OriginCountry: []string{"TR"}, + }, + }, + } + if got := profile.ResolveCategoryID(dramaMeta); got != "20" { + t.Fatalf("expected Drama category ID 20, got %q", got) + } +} + +func TestProfileName(t *testing.T) { + profile := Profile().Site + + // Title stripping and clean dubs + meta := api.UploadSubject{ + ReleaseName: "The.World.Is.Dancing.S01E04.Differing.Scales.of.Progress.Dual-Audio.1080p.WEB-DL-kotopi", + Tag: "kotopi", + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}, + EpisodeTitle: "Differing Scales of Progress", + } + got := profile.BuildName(meta, config.TrackerConfig{}) + want := "The.World.Is.Dancing.S01E04.1080p.WEB-DL [SUBS]-kotopi" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } + + // Original Spanish title mapping + metaES := api.UploadSubject{ + ReleaseName: "La.Casa.de.Papel.S01E01.1080p.WEBDL-GRP", + Tag: "GRP", + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryTV}, + AudioLanguages: []string{"Spanish"}, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + Title: "Money Heist", + OriginalTitle: "La Casa de Papel", + OriginalLanguage: "es", + }, + }, + } + gotES := profile.BuildName(metaES, config.TrackerConfig{}) + wantES := "La.Casa.de.Papel.S01E01.1080p.WEBDL-GRP" // keeps Spanish + if gotES != wantES { + t.Fatalf("expected %q, got %q", wantES, gotES) + } +} + +func TestRulesSpanishRequirements(t *testing.T) { + reg := trackers.NewRegistry() + profile := Profile() + definition := unit3d.NewWithProfile(profile) + if err := reg.Register(definition); err != nil { + t.Fatalf("register: %v", err) + } + + // Helper to create valid base rule subject that passes default unit3d metadata requirements + newRuleSubject := func() api.RuleSubject { + return api.RuleSubject{ + Identity: api.ExternalIdentity{ + TMDBID: 1, + Category: api.CanonicalCategoryTV, + }, + ProviderMetadata: api.SourceScopedMetadata{ + TMDB: &api.TMDBMetadata{ + TMDBID: 1, + Title: "Example", + }, + }, + Assessments: api.ReleaseAssessments{ + MediaInfoEncodeSettings: api.EncodeSettingsStatusNotApplicable, + }, + } + } + + // 1. Spanish Audio - Should Pass + metaAudio := newRuleSubject() + metaAudio.AudioLanguages = []string{"Spanish"} + failures, err := trackers.EvaluateRulesWithRegistry(context.Background(), reg, "LT", metaAudio, nil) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(failures) > 0 { + t.Fatalf("expected pass, got failures: %+v", failures) + } + + // 2. Spanish Subtitles - Should Pass + metaSubs := newRuleSubject() + metaSubs.SubtitleLanguages = []string{"es"} + failures, err = trackers.EvaluateRulesWithRegistry(context.Background(), reg, "LT", metaSubs, nil) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(failures) > 0 { + t.Fatalf("expected pass, got failures: %+v", failures) + } + + // 3. Neither Spanish Audio nor Subtitles - Should Fail + metaNeither := newRuleSubject() + metaNeither.AudioLanguages = []string{"English"} + metaNeither.SubtitleLanguages = []string{"French"} + failures, err = trackers.EvaluateRulesWithRegistry(context.Background(), reg, "LT", metaNeither, nil) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + if len(failures) == 0 { + t.Fatalf("expected failure for neither language") + } + + hasLanguageRuleFailure := false + for _, f := range failures { + if f.Rule == "language_rule" { + hasLanguageRuleFailure = true + break + } + } + if !hasLanguageRuleFailure { + t.Fatalf("expected language_rule failure, got: %+v", failures) + } +} diff --git a/internal/trackers/impl/unit3d/sites/lt/rules.go b/internal/trackers/impl/unit3d/sites/lt/rules.go new file mode 100644 index 00000000..95241db6 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lt/rules.go @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lt + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns LT's strict Spanish audio-or-subtitle requirements. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + Language: &trackers.LanguageRule{ + Languages: []string{"spanish", "es", "spa"}, + RequireAudio: true, + RequireSubs: true, + }, + } +} diff --git a/internal/trackers/impl/unit3d/sites/lume/profile.go b/internal/trackers/impl/unit3d/sites/lume/profile.go new file mode 100644 index 00000000..a22624a8 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lume/profile.go @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lume + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" +) + +// Profile returns LUME's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "LUME", + BaseURL: "https://luminarr.me", + Rules: Rules(), + BannedPolicy: &trackers.BannedGroupPolicy{ + TRaSHGuideURL: "https://raw.githubusercontent.com/TRaSH-Guides/Guides/refs/heads/master/docs/json/radarr/cf/lq.json", + }, + } +} diff --git a/internal/trackers/impl/unit3d/sites/lume/rules.go b/internal/trackers/impl/unit3d/sites/lume/rules.go new file mode 100644 index 00000000..b5a4644f --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/lume/rules.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package lume + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules strictly requires valid MediaInfo encode settings and enforces LUME's +// non-disc container, resolution, and language limits. Adult failures remain +// waivable; language failures are strict. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + RequireValidMISetting: true, + BlockAdult: true, + AdultMessage: "Porn is not allowed on LUME.", + Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + ApplyIfNonDisc: true, + }, + Check: checkRequirements, + } +} + +func checkRequirements(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + failures := make([]api.RuleFailure, 0, 2) + if !unit3d.IsDiscType(meta.DiscType) && !strings.EqualFold(strings.TrimSpace(meta.Container), "mkv") { + failures = append(failures, trackers.NewRuleFailure("container", "LUME only allows MKV containers for non-disc uploads.", api.RuleDispositionStrict)) + } + if unit3d.IsDiscType(meta.DiscType) { + return failures, nil + } + resolution := unit3d.RuleResolution(meta) + if resolution == "" { + failures = append(failures, trackers.NewRuleFailure("resolution_required", "LUME requires a known resolution", api.RuleDispositionStrict)) + return failures, nil + } + if unit3d.ResolutionBelow(resolution, "720p") { + failures = append(failures, trackers.NewRuleFailure( + "min_resolution", + "LUME only allows SD releases when the content does not have a higher resolution release.", + api.RuleDispositionStrict, + )) + } + return failures, nil +} diff --git a/internal/trackers/impl/unit3d/sites/mns/profile.go b/internal/trackers/impl/unit3d/sites/mns/profile.go new file mode 100644 index 00000000..e558db98 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/mns/profile.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mns + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns MNS's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "MNS", + BaseURL: "https://midnightscene.cc", + Rules: Rules(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/mns/rules.go b/internal/trackers/impl/unit3d/sites/mns/rules.go new file mode 100644 index 00000000..c6509af1 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/mns/rules.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package mns + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns MNS's waivable adult-content prohibition. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{BlockAdult: true, AdultMessage: "Adult content is not allowed"} +} diff --git a/internal/trackers/impl/unit3d/sites/oe/banned_groups.go b/internal/trackers/impl/unit3d/sites/oe/banned_groups.go new file mode 100644 index 00000000..aeb6e97e --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/oe/banned_groups.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package oe + +// BannedGroups returns OE's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "0neshot", + "3LT0N", + "4K4U", + "4yEo", + "$andra", + "[Oj]", + "AFG", + "AkihitoSubs", + "Alcaide_Kira", + "AniHLS", + "Anime Time", + "AnimeRG", + "AniURL", + "AOC", + "AR", + "AROMA", + "ASW", + "aXXo", + "BakedFish", + "BiTOR", + "BRrip", + "bonkai", + "Cleo", + "CM8", + "C4K", + "CrEwSaDe", + "core", + "d3g", + "DDR", + "DE3PM", + "DeadFish", + "DeeJayAhmed", + "DNL", + "ELiTE", + "EMBER", + "eSc", + "EVO", + "EZTV", + "FaNGDiNG0", + "FGT", + "fenix", + "FUM", + "FRDS", + "FROZEN", + "GalaxyTV", + "GalaxyRG", + "GalaxyRG265", + "GERMini", + "Grym", + "GrymLegacy", + "HAiKU", + "HD2DVD", + "HDTime", + "Hi10", + "HiQVE", + "ION10", + "iPlanet", + "iVy", + "JacobSwaggedUp", + "JIVE", + "Judas", + "KiNGDOM", + "LAMA", + "Leffe", + "LiGaS", + "LOAD", + "LycanHD", + "MeGusta", + "MezRips", + "mHD", + "Mr.Deadpool", + "mSD", + "NemDiggers", + "neoHEVC", + "NeXus", + "nHD", + "nikt0", + "nSD", + "NhaNc3", + "NOIVTC", + "pahe.in", + "PlaySD", + "playXD", + "PRODJi", + "ProRes", + "project-gxs", + "PSA", + "QaS", + "Ranger", + "RAPiDCOWS", + "RARBG", + "Raze", + "RCDiVX", + "RDN", + "Reaktor", + "REsuRRecTioN", + "RMTeam", + "ROBOTS", + "rubix", + "SANTi", + "SHUTTERSHIT", + "SM737", + "SpaceFish", + "SPASM", + "SSA", + "TBS", + "Telly", + "Tenrai-Sensei", + "TERMiNAL", + "TGx", + "TM", + "topaz", + "ToVaR", + "TSP", + "TSPxL", + "UnKn0wn", + "URANiME", + "UTR", + "VipapkSudios", + "ViSION", + "WAF", + "Wardevil", + "x0r", + "xRed", + "XS", + "YakuboEncodes", + "YAWNTiC", + "YAWNiX", + "YIFY", + "YTS", + "YuiSubs", + "ZKBL", + "ZmN", + "ZMNT", + } +} diff --git a/internal/trackers/impl/unit3d/sites/oe/profile.go b/internal/trackers/impl/unit3d/sites/oe/profile.go new file mode 100644 index 00000000..a6c55603 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/oe/profile.go @@ -0,0 +1,89 @@ +package oe + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns OE's no-group naming, codec-specific encode types, and +// tracker-owned dupe, image-host, rule, and banned-group policies. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "OE", + BaseURL: "https://onlyencodes.cc", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveTypeID: typeID, + }, + DupePolicy: &trackers.DupePolicy{ + AllowSizeVariance1080: true, + }, + ImageHost: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"imgbox", "imgbb", "onlyimage", "ptscreens", "passtheimage"}, + }, + } +} +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + name = strings.TrimSpace(strings.Join(strings.Fields(name), " ")) + tag := strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")) + if tag != "" && !strings.EqualFold(tag, "nogrp") && !strings.EqualFold(tag, "nogroup") && !strings.EqualFold(tag, "unknown") && + !strings.EqualFold(tag, "-unk-") { + return name + } + if name == "" || strings.HasSuffix(strings.ToUpper(name), "-NOGRP") { + return name + } + return name + "-NOGRP" +} + +func typeID(meta api.UploadSubject) string { + typeValue := unit3d.InferType(meta) + if typeValue == "DVDRIP" { + typeValue = "ENCODE" + } + switch typeValue { + case "DISC": + return "19" + case "REMUX": + return "20" + case "WEBDL": + return "21" + case "WEBRIP", "ENCODE": + switch normalizeCodec(meta.VideoCodec) { + case "HEVC": + return "10" + case "AV1": + return "14" + case "AVC": + return "15" + default: + return "16" + } + default: + return "16" + } +} + +func normalizeCodec(value string) string { + codec := strings.ToUpper(strings.TrimSpace(value)) + switch { + case strings.Contains(codec, "AV1"): + return "AV1" + case strings.Contains(codec, "HEVC") || strings.Contains(codec, "H.265") || strings.Contains(codec, "H265") || strings.Contains(codec, "X265"): + return "HEVC" + case strings.Contains(codec, "AVC") || strings.Contains(codec, "H.264") || strings.Contains(codec, "H264") || strings.Contains(codec, "X264"): + return "AVC" + default: + return "" + } +} diff --git a/internal/trackers/impl/unit3d/sites/oe/rules.go b/internal/trackers/impl/unit3d/sites/oe/rules.go new file mode 100644 index 00000000..a150deae --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/oe/rules.go @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package oe + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns OE's waivable adult-content restriction and strict non-disc +// English-language restriction. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + AdultMessage: "Porn is not allowed", + Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + ApplyIfNonDisc: true, + }, + } +} diff --git a/internal/trackers/impl/unit3d/sites/otw/banned_groups.go b/internal/trackers/impl/unit3d/sites/otw/banned_groups.go new file mode 100644 index 00000000..6766af32 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/otw/banned_groups.go @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package otw + +// BannedGroups returns OTW's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "[Oj]", + "3LTON", + "4yEo", + "ADE", + "AFG", + "AniHLS", + "AnimeRG", + "AniURL", + "AROMA", + "aXXo", + "CM8", + "CrEwSaDe", + "DeadFish", + "DNL", + "ELiTE", + "eSc", + "FaNGDiNG0", + "FGT", + "Flights", + "FRDS", + "FUM", + "GalaxyRG", + "HAiKU", + "HD2DVD", + "HDS", + "HDTime", + "Hi10", + "INFINITY", + "ION10", + "iPlanet", + "JIVE", + "KiNGDOM", + "LAMA", + "Leffe", + "LOAD", + "mHD", + "NhaNc3", + "nHD", + "NOIVTC", + "nSD", + "PiRaTeS", + "PRODJi", + "RAPiDCOWS", + "RARBG", + "RDN", + "REsuRRecTioN", + "RMTeam", + "SANTi", + "SicFoI", + "SPASM", + "STUTTERSHIT", + "Telly", + "TM", + "UPiNSMOKE", + "WAF", + "xRed", + "XS", + "YELLO", + "YIFY", + "YTS", + "ZKBL", + "ZmN", + "4f8c4100292", + "Azkars", + "Sync0rdi", + } +} diff --git a/internal/trackers/impl/unit3d/sites/otw/profile.go b/internal/trackers/impl/unit3d/sites/otw/profile.go new file mode 100644 index 00000000..8b6302bb --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/otw/profile.go @@ -0,0 +1,47 @@ +package otw + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns OTW's disc and DVDRip type mapping together with duplicate +// resolution-mismatch rejection, rules, and banned groups. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "OTW", + BaseURL: "https://oldtoons.world", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + }, + DupePolicy: &trackers.DupePolicy{ + RejectEpisodeResolutionMismatch: true, + }, + } +} + +func typeID(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "1" + } + if unit3d.IsDiscType(meta.DiscType) { + return "7" + } + typeValue := unit3d.InferType(meta) + if typeValue == "DVDRIP" { + return "8" + } + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "ENCODE": "3", + }[typeValue] +} diff --git a/internal/trackers/impl/unit3d/sites/otw/rules.go b/internal/trackers/impl/unit3d/sites/otw/rules.go new file mode 100644 index 00000000..5e18a9f1 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/otw/rules.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package otw + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules returns OTW's waivable genre, adult-content, reality-content, and +// release-group restrictions. +func Rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkGenres} } + +func checkGenres(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + failures := make([]api.RuleFailure, 0, 3) + genres := unit3d.RuleGenres(meta) + if !unit3d.ContainsRuleValue(genres, []string{"animation", "family"}) { + failures = append(failures, trackers.NewRuleFailure("genre", "Genre does not match Animation or Family for OTW.", api.RuleDispositionWaivable)) + } + if unit3d.AdultContent(meta) { + failures = append(failures, trackers.NewRuleFailure("block_adult", "Adult animation not allowed at OTW.", api.RuleDispositionWaivable)) + } + if unit3d.ContainsRuleValue(genres, []string{"reality", "game show", "game-show", "reality tv", "reality television"}) { + failures = append(failures, trackers.NewRuleFailure("block_reality", "Reality / Game Show content not allowed at OTW.", api.RuleDispositionWaivable)) + } + typeValue := unit3d.RuleType(meta) + group := unit3d.RuleGroup(meta) + if group != "" && typeValue != "WEBDL" && !unit3d.IsDiscType(meta.DiscType) { + restricted := map[string]bool{ + "CMRG": true, + "EVO": true, + "TERMINAL": true, + "VISION": true, + } + if restricted[strings.ToUpper(group)] { + failures = append(failures, trackers.NewRuleFailure( + "group_type", + fmt.Sprintf("Group %s is only allowed for raw type content at OTW", group), + api.RuleDispositionWaivable, + )) + } + } + return failures, nil +} diff --git a/internal/trackers/impl/unit3d/sites/pt/profile.go b/internal/trackers/impl/unit3d/sites/pt/profile.go new file mode 100644 index 00000000..d1e8a9cc --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/pt/profile.go @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package pt + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns PT's type and resolution mappings and adds European +// Portuguese audio and subtitle flags to the upload payload. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "PT", + BaseURL: "https://portugas.org", + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + ApplyAdditionalPayload: additionalPayload, + }, + } +} + +func additionalPayload(req trackers.PreparationInput, data map[string]string) { + data["audio_pt"] = boolString(hasEuropeanPortuguese(req.Meta.AudioLanguages)) + data["legenda_pt"] = boolString(hasEuropeanPortuguese(req.Meta.SubtitleLanguages)) +} + +func boolString(value bool) string { + if value { + return "1" + } + return "0" +} + +func hasEuropeanPortuguese(languages []string) bool { + for _, language := range languages { + lower := strings.ToLower(strings.TrimSpace(language)) + if lower == "" || strings.Contains(lower, "brazil") || strings.Contains(lower, "brasil") || strings.Contains(lower, "pt-br") || + strings.Contains(lower, "ptbr") { + continue + } + if strings.Contains(lower, "portuguese") || lower == "pt" || strings.Contains(lower, "português") { + return true + } + } + return false +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "WEBDL": "4", + "WEBRIP": "39", + "HDTV": "6", + "ENCODE": "3", + }[unit3d.InferType(meta)] +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1440p": "13", + "1080p": "3", + "1080i": "4", + "720p": "5", + "576p": "6", + "576i": "7", + "540p": "11", + "480p": "8", + "480i": "9", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/ptt/banned_groups.go b/internal/trackers/impl/unit3d/sites/ptt/banned_groups.go new file mode 100644 index 00000000..f57c7a71 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ptt/banned_groups.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptt + +// BannedGroups returns PTT's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "ViP", + "BiRD", + "M@RTiNU$", + "inTGrity", + "CiNEMAET", + "MusicET", + "TeamET", + "R2D2", + } +} diff --git a/internal/trackers/impl/unit3d/sites/ptt/profile.go b/internal/trackers/impl/unit3d/sites/ptt/profile.go new file mode 100644 index 00000000..21a4a250 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ptt/profile.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ptt + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns PTT's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "PTT", + BaseURL: "https://polishtorrent.top", + BannedGroups: BannedGroups(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/r4e/profile.go b/internal/trackers/impl/unit3d/sites/r4e/profile.go new file mode 100644 index 00000000..e17ab90c --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/r4e/profile.go @@ -0,0 +1,45 @@ +package r4e + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns R4E's category mapping, which separates movie and TV content +// and assigns documentary categories from TMDB genre ID 99. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "R4E", + BaseURL: "https://racing4everyone.eu", + Site: unit3d.SiteProfile{ + ResolveCategoryID: categoryID, + }, + } +} +func categoryID(meta api.UploadSubject) string { + genreIDs := "" + if meta.ProviderMetadata.TMDB != nil { + genreIDs = meta.ProviderMetadata.TMDB.GenreIDs + } + isDoc := false + for value := range strings.SplitSeq(genreIDs, ",") { + if strings.TrimSpace(value) == "99" { + isDoc = true + break + } + } + switch { + case strings.EqualFold(unit3d.Category(meta), "MOVIE") && isDoc: + return "66" + case strings.EqualFold(unit3d.Category(meta), "MOVIE"): + return "70" + case strings.EqualFold(unit3d.Category(meta), "TV") && isDoc: + return "2" + case strings.EqualFold(unit3d.Category(meta), "TV"): + return "79" + default: + return "24" + } +} diff --git a/internal/trackers/impl/unit3d/sites/ras/banned_groups.go b/internal/trackers/impl/unit3d/sites/ras/banned_groups.go new file mode 100644 index 00000000..c5d67dac --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ras/banned_groups.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ras + +// BannedGroups returns RAS's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "YTS", + "YiFY", + "LAMA", + "MeGUSTA", + "NAHOM", + "GalaxyRG", + "RARBG", + "INFINITY", + } +} diff --git a/internal/trackers/impl/unit3d/sites/ras/profile.go b/internal/trackers/impl/unit3d/sites/ras/profile.go new file mode 100644 index 00000000..c3fa3699 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ras/profile.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ras + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns RAS's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "RAS", + BaseURL: "https://rastastugan.org", + Rules: Rules(), + BannedGroups: BannedGroups(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/ras/rules.go b/internal/trackers/impl/unit3d/sites/ras/rules.go new file mode 100644 index 00000000..24ff59df --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ras/rules.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ras + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns RAS's strict Nordic-or-English audio-or-subtitle requirement. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{Language: &trackers.LanguageRule{ + Languages: []string{"english", "norwegian", "norsk", "no", "nb", "nn", "swedish", "sv", "danish", "da", "finnish", "fi", "icelandic", "is"}, + RequireAudio: true, + RequireSubs: true, + }} +} diff --git a/internal/trackers/impl/unit3d/sites/rf/profile.go b/internal/trackers/impl/unit3d/sites/rf/profile.go new file mode 100644 index 00000000..74b1b92a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rf/profile.go @@ -0,0 +1,83 @@ +package rf + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns RF's no-group naming, site-specific type and resolution +// mappings, required release-group dupe policy, and conditional owned image host. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "RF", + BaseURL: "https://reelflix.cc", + Rules: Rules(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + }, + DupePolicy: &trackers.DupePolicy{ + RequireReleaseGroup: true, + }, + ImageHost: &trackers.ImageHostPolicy{ + ConditionalHost: "reelflix", + OwnedHosts: []string{"reelflix"}, + EnableWhenConfigured: true, + }, + TorrentIdentity: &trackers.TorrentIdentityPolicy{ + TrackerURLPatterns: []string{"https://reelflix.xyz"}, + CommentURLPatterns: []string{"https://reelflix.xyz"}, + DetailIDPattern: `(?i)reelflix\.(?:cc|xyz)/torrents/(\d+)`, + }, + } +} +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + return addNoGroup(meta, "NoGroup") +} +func addNoGroup(meta api.UploadSubject, suffix string) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + name = strings.TrimSpace(strings.Join(strings.Fields(name), " ")) + tag := strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-")) + if tag != "" && !strings.EqualFold(tag, "nogrp") && !strings.EqualFold(tag, "nogroup") && !strings.EqualFold(tag, "unknown") && + !strings.EqualFold(tag, "-unk-") { + return name + } + if name == "" || strings.HasSuffix(strings.ToUpper(name), "-"+strings.ToUpper(suffix)) { + return name + } + return name + "-" + suffix +} +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "43", + "REMUX": "40", + "WEBDL": "42", + "WEBRIP": "45", + "ENCODE": "41", + "HDTV": "35", + }[unit3d.InferType(meta)] +} +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + "720p": "5", + "576p": "6", + "576i": "7", + "480p": "8", + "480i": "9", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} diff --git a/internal/trackers/impl/unit3d/sites/rf/rules.go b/internal/trackers/impl/unit3d/sites/rf/rules.go new file mode 100644 index 00000000..cf91fafb --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rf/rules.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rf + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly rejects known non-movie categories; its adult-content failure +// remains waivable. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + AdultMessage: "Porn is not allowed", + RequireMovieOnly: true, + } +} diff --git a/internal/trackers/impl/unit3d/sites/rhd/banned_groups.go b/internal/trackers/impl/unit3d/sites/rhd/banned_groups.go new file mode 100644 index 00000000..a9fc05ec --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rhd/banned_groups.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rhd + +// BannedGroups returns RHD's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "1XBET", + "MEGA", + "MTZ", + "Whistler", + "WOTT", + "Taylor.D", + "HELD", + "FSX", + "FuN", + "MagicX", + "w00t", + "PaTroL", + "BB", + "266ers", + "GTF", + "JellyfinPlex", + "2BA", + "FritzBox", + "FUNXDTV", + } +} diff --git a/internal/trackers/impl/unit3d/sites/rhd/profile.go b/internal/trackers/impl/unit3d/sites/rhd/profile.go new file mode 100644 index 00000000..f44a522b --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rhd/profile.go @@ -0,0 +1,285 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rhd + +import ( + "regexp" + "slices" + "strconv" + "strings" + + "golang.org/x/text/language" + "golang.org/x/text/language/display" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + regradedRegex = tokenRegex(`regraded`) + upscaleRegex = tokenRegex(`upscaled?`, `upscl`, `upsuhd`) + internalRegex = tokenRegex(`internal`) + incompleteRegex = tokenRegex(`incomplete`) + dubbedRegex = tokenRegex(`dubbed`, `synced`, `ac3d`, `ld`, `line`, `mic`, `md`) +) + +// Profile returns RHD's German-oriented release-name construction, resolution +// mapping, tracker-owned rules, and banned groups. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "RHD", + BaseURL: "https://rocket-hd.cc", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveResolutionID: resolutionID, + }, + } +} + +func tokenRegex(tokens ...string) *regexp.Regexp { + return regexp.MustCompile(`(?i)(^|[^[:alnum:]])(?:` + strings.Join(tokens, "|") + `)([^[:alnum:]]|$)`) +} + +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + "720p": "5", + "576p": "12", + "576i": "13", + "480p": "11", + "480i": "18", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "10" +} + +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + parts := make([]string, 0) + fullDisc := strings.EqualFold(strings.TrimSpace(meta.Type), "DISC") || unit3d.IsDiscType(meta.DiscType) + markers := markerText(meta) + title := "" + tmdb := meta.ProviderMetadata.TMDB + if tmdb != nil && tmdb.LocalizedTitles != nil { + title = strings.TrimSpace(tmdb.LocalizedTitles["de"]) + } + if title == "" { + title = strings.TrimSpace(meta.Release.Title) + } + if title == "" && tmdb != nil { + title = strings.TrimSpace(tmdb.Title) + } + if title == "" && tmdb != nil { + title = strings.TrimSpace(tmdb.OriginalTitle) + } + if title != "" { + parts = append(parts, title) + } + year := meta.Release.Year + if year == 0 && tmdb != nil { + year = tmdb.Year + } + if year == 0 { + parts = append(parts, "0000") + } else { + parts = append(parts, strconv.Itoa(year)) + } + if meta.SeasonStr != "" || meta.EpisodeStr != "" { + parts = append(parts, strings.TrimSpace(meta.SeasonStr+meta.EpisodeStr)) + if incompleteRegex.MatchString(markers) { + parts = append(parts, "iNCOMPLETE") + } + } + if meta.Edition != "" { + parts = append(parts, meta.Edition) + } + if meta.Is3D != "" { + parts = append(parts, meta.Is3D) + } + if !fullDisc { + parts = append(parts, resolveLanguage(meta)) + } + if meta.Repack != "" { + parts = append(parts, meta.Repack) + } + if meta.Release.Resolution != "" { + parts = append(parts, meta.Release.Resolution) + if regradedRegex.MatchString(markers) { + parts = append(parts, "REGRADED") + } + if upscaleRegex.MatchString(markers) { + parts = append(parts, "UPSCALE") + } + } + if strings.Contains(strings.ToUpper(meta.Type), "WEB") && meta.Service != "" { + parts = append(parts, meta.Service) + } else if meta.UHD != "" { + parts = append(parts, meta.UHD) + } + parts = append(parts, typeAndSource(meta)...) + if meta.Audio != "" { + parts = append(parts, meta.Audio) + } + if meta.Channels != "" && !strings.Contains(meta.Audio, meta.Channels) { + parts = append(parts, meta.Channels) + } + if meta.HDR != "" { + parts = append(parts, meta.HDR) + } + if meta.BitDepth != "" && meta.BitDepth != "8" && meta.BitDepth != "0" { + if strings.Contains(strings.ToLower(meta.BitDepth), "bit") { + parts = append(parts, meta.BitDepth) + } else { + parts = append(parts, meta.BitDepth+"bit") + } + } + codec := meta.VideoEncode + if codec == "" { + codec = meta.VideoCodec + } + if codec != "" { + parts = append(parts, codec) + } + if internalRegex.MatchString(markers) { + parts = append(parts, "iNTERNAL") + } + group := meta.Tag + if group == "" || unit3d.IsNoGroupTag(group) { + group = "NOGRP" + } else { + group = strings.TrimPrefix(group, "-") + } + return strings.Join(strings.Fields(strings.Join(parts, " ")), " ") + "-" + group +} + +func typeAndSource(meta api.UploadSubject) []string { + parts := []string{} + name := strings.TrimSpace(meta.Type) + if name == "" && unit3d.IsDiscType(meta.DiscType) { + name = "DISC" + } + if name == "" { + return nil + } + switch strings.ToUpper(name) { + case "WEBDL": + name = "WEB-DL" + case "WEBRIP": + name = "WEBRip" + case "ENCODE": + if meta.Source != "" { + parts = append(parts, meta.Source) + name = "" + } + case "REMUX": + if meta.Source != "" { + parts = append(parts, meta.Source) + } + case "DISC": + parts = append(parts, "COMPLETE") + if meta.Region != "" { + parts = append(parts, meta.Region) + } + if meta.Release.Source != "" { + parts = append(parts, meta.Release.Source) + } + if meta.Release.Size != "" { + parts = append(parts, meta.Release.Size) + } + name = "" + } + if name != "" { + parts = append(parts, name) + } + return parts +} + +func markerText(meta api.UploadSubject) string { + value, tag := strings.TrimSpace(meta.ReleaseName), strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-") + if value == "" || tag == "" { + return value + } + for _, suffix := range []string{"-" + tag, "." + tag, "_" + tag, " " + tag} { + if strings.HasSuffix(strings.ToLower(value), strings.ToLower(suffix)) { + return strings.TrimSpace(value[:len(value)-len(suffix)]) + } + } + return value +} + +func resolveLanguage(meta api.UploadSubject) string { + languages := normalizedAudioLanguages(meta.AudioLanguages) + german := slices.ContainsFunc(languages, isGerman) + var base string + switch { + case german: + base = "GERMAN" + case slices.ContainsFunc(meta.SubtitleLanguages, isGerman): + return "GERMAN SUBBED" + case len(languages) > 0: + base = languageName(languages[0]) + default: + base = "ENGLISH" + } + if dubbedRegex.MatchString(markerText(meta)) { + base += " DUBBED" + } + if len(languages) == 2 { + return base + " DL" + } + if len(languages) > 2 { + return base + " ML" + } + return base +} + +func normalizedAudioLanguages(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + tag, ok := unit3d.ParseLanguageTag(value) + if !ok { + continue + } + base, _ := tag.Base() + key := base.String() + if key == "" || key == "und" { + continue + } + if isGerman(key) { + key = "de" + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} +func isGerman(value string) bool { + tag, ok := unit3d.ParseLanguageTag(value) + if !ok { + return slices.Contains([]string{"german", "ger", "de", "deu", "gsw"}, strings.ToLower(strings.TrimSpace(value))) + } + base, _ := tag.Base() + return base.String() == "de" || base.String() == "gsw" +} +func languageName(value string) string { + tag, ok := unit3d.ParseLanguageTag(value) + if !ok { + return strings.ToUpper(strings.TrimSpace(value)) + } + name := display.Languages(language.English).Name(tag) + if name == "" { + return strings.ToUpper(strings.TrimSpace(value)) + } + return strings.ToUpper(name) +} diff --git a/internal/trackers/impl/unit3d/sites/rhd/profile_test.go b/internal/trackers/impl/unit3d/sites/rhd/profile_test.go new file mode 100644 index 00000000..4ff94648 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rhd/profile_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rhd + +import ( + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestProfileNameParity(t *testing.T) { + build := Profile().Site.BuildName + tests := []struct { + name string + meta api.UploadSubject + want string + }{ + { + name: "localized web", + meta: api.UploadSubject{ + Type: "WEBDL", + Tag: "-GRP", + Audio: "DD+ 5.1", + VideoEncode: "H.264", + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{Year: 2025, LocalizedTitles: map[string]string{"de": "Beispiel Film"}}}, + }, + want: "Beispiel Film 2025 GERMAN 1080p WEB-DL DD+ 5.1 H.264-GRP", + }, + { + name: "full disc", + meta: api.UploadSubject{ + Type: "DISC", + Region: "GER", + Tag: "-GRP", + Audio: "DTS-HD MA 5.1", + VideoCodec: "AVC", + AudioLanguages: []string{"German", "English"}, + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2024, + Resolution: "1080p", + Source: "Blu-ray", + Size: "BD50", + }, + }, + want: "Example Movie 2024 1080p COMPLETE GER Blu-ray BD50 DTS-HD MA 5.1 AVC-GRP", + }, + { + name: "markers", + meta: api.UploadSubject{ + ReleaseName: "Example.Movie.2024.[INTERNAL].(UPSCALED).1080p.WEB-DL.DDP5.1.H.264-GRP", + Type: "WEBDL", + Tag: "-GRP", + Audio: "DDP5.1", + VideoEncode: "H.264", + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2024, + Resolution: "1080p", + }, + }, + want: "Example Movie 2024 GERMAN 1080p UPSCALE WEB-DL DDP5.1 H.264 iNTERNAL-GRP", + }, + { + name: "hdr", + meta: api.UploadSubject{ + Type: "WEBDL", + Tag: "-GRP", + Audio: "DDP5.1", + HDR: "DV HDR", + VideoEncode: "H.265", + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2026, + Resolution: "2160p", + }, + }, + want: "Example Movie 2026 GERMAN 2160p WEB-DL DDP5.1 DV HDR H.265-GRP", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := build(test.meta, config.TrackerConfig{}); got != test.want { + t.Fatalf("name = %q, want %q", got, test.want) + } + }) + } + ignored := build(api.UploadSubject{ + ReleaseName: "Example.Movie.2024.Regradedness.Internalized.Lineage.1080p.WEB-DL.DDP5.1.H.264-LD", + Type: "WEBDL", + Tag: "-LD", + Audio: "DDP5.1", + VideoEncode: "H.264", + AudioLanguages: []string{"English"}, + Release: api.ReleaseInfo{ + Title: "Example Movie", + Year: 2024, + Resolution: "1080p", + }, + }, config.TrackerConfig{}) + for _, marker := range []string{"REGRADED", "UPSCALE", "iNTERNAL", "DUBBED"} { + if strings.Contains(ignored, marker) { + t.Fatalf("unexpected marker %s in %q", marker, ignored) + } + } +} + +func TestProfileResolutionAndLanguages(t *testing.T) { + if got := Profile().Site.ResolveResolutionID(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "576p"}}); got != "12" { + t.Fatalf("resolution = %q", got) + } + for _, test := range []struct { + values []string + want string + }{{[]string{"", "French", " "}, "FRENCH"}, {[]string{"English", "eng", "English"}, "ENGLISH"}, {[]string{"German", "deu", "de-DE"}, "GERMAN"}, {[]string{"English", "French"}, "ENGLISH DL"}} { + if got := resolveLanguage(api.UploadSubject{AudioLanguages: test.values}); got != test.want { + t.Fatalf("language = %q, want %q", got, test.want) + } + } +} diff --git a/internal/trackers/impl/unit3d/sites/rhd/rules.go b/internal/trackers/impl/unit3d/sites/rhd/rules.go new file mode 100644 index 00000000..5a000ec6 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/rhd/rules.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package rhd + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly requires a known resolution of at least 720p and an NFO for +// scene releases. Adult-content failures remain waivable; German-audio failures +// are strict. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + MinResolution: "720p", + Language: &trackers.LanguageRule{Languages: []string{"german", "ger", "de", "deu", "gsw"}, RequireAudio: true}, + RequireSceneNFO: true, + } +} diff --git a/internal/trackers/impl/unit3d/sites/sam/profile.go b/internal/trackers/impl/unit3d/sites/sam/profile.go new file mode 100644 index 00000000..bc07a2a4 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/sam/profile.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package sam + +import ( + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns SAM's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "SAM", + BaseURL: "https://samaritano.cc", + Site: unit3d.SiteProfile{ + BuildName: buildName, + }, + } +} +func buildName(meta api.UploadSubject, cfg config.TrackerConfig) string { + return unit3d.FormatLocalizedName(meta, cfg.TagForCustomRelease) +} diff --git a/internal/trackers/impl/unit3d/sites/shri/profile.go b/internal/trackers/impl/unit3d/sites/shri/profile.go new file mode 100644 index 00000000..8c1b3594 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/shri/profile.go @@ -0,0 +1,85 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package shri + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns SHRI's type mapping, numeric region/distributor payload +// fields, and idempotent Island-group description footer. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "SHRI", + BaseURL: "https://shareisland.org", + Rules: Rules(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ApplyAdditionalPayload: additionalPayload, + FinalizeDescription: finalizeDescription, + }, + } +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "26", + "REMUX": "7", + "WEBDL": "27", + "WEBRIP": "15", + "HDTV": "33", + "ENCODE": "15", + "DVDRIP": "15", + }[unit3d.InferType(meta)] +} + +func additionalPayload(req trackers.PreparationInput, data map[string]string) { + if value := numericValue(req.Meta.Region); value != "" { + data["region_id"] = value + } + if value := numericValue(req.Meta.Distributor); value != "" { + data["distributor_id"] = value + } +} + +func finalizeDescription(description string, meta api.UploadSubject) string { + if !strings.EqualFold(releaseGroup(meta), "island") { + return description + } + const notes = "Release Shareisland 🏴‍☠️\nFalla girare, condividila e contribuisci a mantenerla viva restando in seed il più possi" + "bile.\nGrazie per il supporto!" + trimmed := strings.TrimSpace(description) + if strings.Contains(trimmed, notes) { + return trimmed + } + if trimmed == "" { + return notes + } + return trimmed + "\n\n" + notes +} + +func releaseGroup(meta api.UploadSubject) string { + for _, value := range []string{meta.Release.Group, meta.ArrReleaseGroup, strings.TrimPrefix(meta.Tag, "-")} { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func numericValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + for _, r := range trimmed { + if r < '0' || r > '9' { + return "" + } + } + return trimmed +} diff --git a/internal/trackers/impl/unit3d/sites/shri/rules.go b/internal/trackers/impl/unit3d/sites/shri/rules.go new file mode 100644 index 00000000..e0c6bc4c --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/shri/rules.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package shri + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules strictly requires a region for DVD and HDDVD uploads. +func Rules() *trackers.RuleSet { return &trackers.RuleSet{Check: checkRegion} } + +func checkRegion(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") && !strings.EqualFold(strings.TrimSpace(meta.DiscType), "HDDVD") { + return nil, nil + } + if strings.TrimSpace(meta.Region) == "" { + return []api.RuleFailure{trackers.NewRuleFailure("region_required", "Region required; skipping SHRI.", api.RuleDispositionStrict)}, nil + } + return nil, nil +} diff --git a/internal/trackers/impl/unit3d/sites/sp/profile.go b/internal/trackers/impl/unit3d/sites/sp/profile.go new file mode 100644 index 00000000..eeccc496 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/sp/profile.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package sp + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns SP's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "SP", + BaseURL: "https://seedpool.org", + Rules: Rules(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/sp/rules.go b/internal/trackers/impl/unit3d/sites/sp/rules.go new file mode 100644 index 00000000..ac6d48a5 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/sp/rules.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package sp + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly requires a known resolution of at least 1080p; its +// adult-content failure remains waivable. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + AdultMessage: "Porn is not allowed", + MinResolution: "1080p", + } +} diff --git a/internal/trackers/impl/unit3d/sites/stc/profile.go b/internal/trackers/impl/unit3d/sites/stc/profile.go new file mode 100644 index 00000000..b414f04a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/stc/profile.go @@ -0,0 +1,49 @@ +package stc + +import ( + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns STC's type mapping, including dedicated TV-pack IDs split by +// web source and SD status, plus its image-host and rule policies. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "STC", + BaseURL: "https://skipthecommercials.xyz", + Rules: Rules(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + }, + ImageHost: &trackers.ImageHostPolicy{ + AllowedHosts: []string{"imgbox", "imgbb"}, + }, + } +} + +func typeID(meta api.UploadSubject) string { + typeValue := unit3d.InferType(meta) + if meta.TVPack { + isWeb := typeValue == "WEBDL" || typeValue == "WEBRIP" + sd := unit3d.IsSDResolution(unit3d.Resolution(meta)) + if sd && isWeb { + return "14" + } + if sd { + return "17" + } + if isWeb { + return "13" + } + return "18" + } + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "ENCODE": "3", + }[typeValue] +} diff --git a/internal/trackers/impl/unit3d/sites/stc/rules.go b/internal/trackers/impl/unit3d/sites/stc/rules.go new file mode 100644 index 00000000..fa28a20a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/stc/rules.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package stc + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly rejects known non-TV categories; its adult-content failure +// remains waivable. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + BlockAdult: true, + AdultMessage: "Porn is not allowed", + RequireTVOnly: true, + } +} diff --git a/internal/trackers/impl/unit3d/sites/tik/profile.go b/internal/trackers/impl/unit3d/sites/tik/profile.go new file mode 100644 index 00000000..1182d90d --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tik/profile.go @@ -0,0 +1,173 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tik + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns TIK's disc-type and category mappings, honoring explicit +// tracker-site overrides before metadata-derived classification. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "TIK", + BaseURL: "https://cinematik.net", + Rules: Rules(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveCategoryID: categoryID, + }, + } +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "CUSTOM": "1", + "BD100": "3", + "BD66": "4", + "BD50": "5", + "BD25": "6", + "NTSC DVD9": "7", + "NTSC DVD5": "8", + "PAL DVD9": "9", + "PAL DVD5": "10", + "3D": "11", + }[discType(meta)] +} + +func discType(meta api.UploadSubject) string { + if meta.TrackerSiteOverrides.TIK.DiscType != nil { + return strings.ToUpper(strings.TrimSpace(*meta.TrackerSiteOverrides.TIK.DiscType)) + } + if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { + return "3D" + } + releaseName := strings.ToUpper(strings.TrimSpace(meta.ReleaseName)) + source := strings.ToUpper(strings.TrimSpace(meta.Source)) + if source == "" { + source = strings.ToUpper(strings.TrimSpace(meta.Release.Source)) + } + combined := releaseName + " " + source + for _, marker := range []string{"BD100", "BD66", "BD50", "BD25"} { + if strings.Contains(combined, marker) { + return marker + } + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || strings.Contains(combined, "DVD") { + if strings.Contains(combined, "PAL") { + if strings.Contains(combined, "DVD5") { + return "PAL DVD5" + } + return "PAL DVD9" + } + if strings.Contains(combined, "NTSC") { + if strings.Contains(combined, "DVD5") { + return "NTSC DVD5" + } + return "NTSC DVD9" + } + if strings.Contains(combined, "DVD5") { + return "NTSC DVD5" + } + return "NTSC DVD9" + } + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { + return "CUSTOM" + } + return "" +} + +func categoryID(meta api.UploadSubject) string { + category := unit3d.Category(meta) + foreign, opera, asian := isForeign(meta), isOpera(meta), isAsian(meta) + if strings.EqualFold(category, "MOVIE") { + switch { + case foreign: + return "3" + case opera: + return "5" + case asian: + return "6" + default: + return "1" + } + } + if strings.EqualFold(category, "TV") { + switch { + case foreign: + return "4" + case opera: + return "5" + default: + return "2" + } + } + return unit3d.DefaultCategoryID(meta) +} + +func isForeign(meta api.UploadSubject) bool { + if meta.TrackerSiteOverrides.TIK.Foreign != nil { + return *meta.TrackerSiteOverrides.TIK.Foreign + } + if meta.ProviderMetadata.TMDB != nil { + original := strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage)) + if original != "" && original != "en" { + return true + } + } + return !unit3d.HasEnglishLanguage(meta.AudioLanguages) && !unit3d.HasEnglishLanguage(meta.SubtitleLanguages) +} + +func isOpera(meta api.UploadSubject) bool { + if meta.TrackerSiteOverrides.TIK.Opera != nil { + return *meta.TrackerSiteOverrides.TIK.Opera + } + values := strings.ToLower( + strings.Join([]string{strings.TrimSpace(meta.Release.Genre), unit3d.TMDBGenres(meta), unit3d.IMDBGenres(meta), unit3d.Keywords(meta)}, ","), + ) + return strings.Contains(values, "opera") || strings.Contains(values, "musical") +} + +func isAsian(meta api.UploadSubject) bool { + if meta.TrackerSiteOverrides.TIK.Asian != nil { + return *meta.TrackerSiteOverrides.TIK.Asian + } + if meta.ProviderMetadata.TMDB == nil { + return false + } + for _, country := range meta.ProviderMetadata.TMDB.OriginCountry { + if map[string]bool{ + "JP": true, + "KR": true, + "CN": true, + "HK": true, + "TW": true, + "TH": true, + "VN": true, + "IN": true, + "ID": true, + "MY": true, + "PH": true, + "SG": true, + }[strings.ToUpper(strings.TrimSpace(country))] { + return true + } + } + return map[string]bool{ + "ja": true, + "ko": true, + "zh": true, + "th": true, + "vi": true, + "hi": true, + "ta": true, + "te": true, + "ml": true, + "id": true, + "ms": true, + }[strings.ToLower(strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage))] +} diff --git a/internal/trackers/impl/unit3d/sites/tik/profile_test.go b/internal/trackers/impl/unit3d/sites/tik/profile_test.go new file mode 100644 index 00000000..877c207a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tik/profile_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tik + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestProfileResolvers(t *testing.T) { + profile := Profile().Site + if got := profile.ResolveTypeID(api.UploadSubject{ReleaseName: "Example.Release.2026.BD50.COMPLETE-GRP", DiscType: "BDMV"}); got != "5" { + t.Fatalf("BD50 type = %q", got) + } + disc := "BD66" + if got := profile.ResolveTypeID(api.UploadSubject{TrackerSiteOverrides: api.TrackerSiteOverrides{TIK: api.TIKOverrides{DiscType: &disc}}}); got != "4" { + t.Fatalf("override type = %q", got) + } + foreign := api.UploadSubject{Identity: api.ExternalIdentity{Category: "MOVIE"}, ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{OriginalLanguage: "fr"}}} + if got := profile.ResolveCategoryID(foreign); got != "3" { + t.Fatalf("foreign category = %q", got) + } + asian := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{OriginalLanguage: "en", OriginCountry: []string{"JP"}}}, + } + if got := profile.ResolveCategoryID(asian); got != "6" { + t.Fatalf("Asian category = %q", got) + } + opera := api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + Release: api.ReleaseInfo{Genre: "Opera"}, + } + if got := profile.ResolveCategoryID(opera); got != "5" { + t.Fatalf("opera category = %q", got) + } +} diff --git a/internal/trackers/impl/unit3d/sites/tik/rules.go b/internal/trackers/impl/unit3d/sites/tik/rules.go new file mode 100644 index 00000000..490cdb9d --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tik/rules.go @@ -0,0 +1,9 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tik + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly permits disc uploads only. +func Rules() *trackers.RuleSet { return &trackers.RuleSet{RequireDiscOnly: true} } diff --git a/internal/trackers/impl/unit3d/sites/tlz/profile.go b/internal/trackers/impl/unit3d/sites/tlz/profile.go new file mode 100644 index 00000000..bc9807f6 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tlz/profile.go @@ -0,0 +1,30 @@ +package tlz + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns TLZ's distinct type IDs for movies, packs, and remaining +// content. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "TLZ", + BaseURL: "https://tlzdigital.com", + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + }, + } +} + +func typeID(meta api.UploadSubject) string { + if strings.EqualFold(unit3d.Category(meta), "MOVIE") { + return "1" + } + if meta.TVPack { + return "4" + } + return "3" +} diff --git a/internal/trackers/impl/unit3d/sites/tos/banned_groups.go b/internal/trackers/impl/unit3d/sites/tos/banned_groups.go new file mode 100644 index 00000000..462c56d2 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tos/banned_groups.go @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tos + +// BannedGroups returns TOS's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "FL3ER", + "SUNS3T", + "WoLFHD", + "EXTREME", + "Slay3R", + "3T3AM", + "BARBiE", + } +} diff --git a/internal/trackers/impl/unit3d/sites/tos/profile.go b/internal/trackers/impl/unit3d/sites/tos/profile.go new file mode 100644 index 00000000..9e0af311 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tos/profile.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tos + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns TOS's DVD/3D type mapping and French-subtitle-aware movie, +// episode, and TV-pack category mapping. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "TOS", + BaseURL: "https://theoldschool.cc", + Rules: Rules(), + BannedGroups: BannedGroups(), + UploadArtifact: &trackers.UploadArtifactPolicy{ + Source: "TheOldSchool", + }, + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveCategoryID: categoryID, + }, + } +} + +func categoryID(meta api.UploadSubject) string { + tag := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-"))) + subFrench := strings.Contains(tag, "vostfr") || strings.Contains(tag, "subfrench") + if strings.EqualFold(unit3d.Category(meta), "TV") { + if meta.TVPack { + if subFrench { + return "9" + } + return "8" + } + if subFrench { + return "7" + } + return "2" + } + if subFrench { + return "6" + } + return "1" +} + +func typeID(meta api.UploadSubject) string { + if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { + return "7" + } + if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { + return "8" + } + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + }[unit3d.InferType(meta)] +} diff --git a/internal/trackers/impl/unit3d/sites/tos/rules.go b/internal/trackers/impl/unit3d/sites/tos/rules.go new file mode 100644 index 00000000..f6f46dfc --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/tos/rules.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package tos + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules strictly requires an NFO for scene releases and applies a strict +// French audio-or-subtitle requirement that accepts original-language audio +// when French subtitles are present. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{Language: &trackers.LanguageRule{ + Languages: []string{"french", "fr", "fra", "fre"}, + RequireAudio: true, + RequireSubs: true, + AllowOriginal: true, + }, RequireSceneNFO: true} +} diff --git a/internal/trackers/impl/unit3d/sites/ttr/profile.go b/internal/trackers/impl/unit3d/sites/ttr/profile.go new file mode 100644 index 00000000..eb60cc7a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ttr/profile.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ttr + +import "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + +// Profile returns TTR's Unit3D site manifest. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "TTR", + BaseURL: "https://torrenteros.org", + Rules: Rules(), + } +} diff --git a/internal/trackers/impl/unit3d/sites/ttr/rules.go b/internal/trackers/impl/unit3d/sites/ttr/rules.go new file mode 100644 index 00000000..e7b9cf82 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ttr/rules.go @@ -0,0 +1,36 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ttr + +import ( + "context" + "fmt" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules returns TTR's strict Spanish audio-or-subtitle requirements. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{Language: &trackers.LanguageRule{ + Languages: []string{"spanish", "es", "spa"}, + RequireAudio: true, + RequireSubs: true, + }, Check: checkSubtitleOnly} +} + +func checkSubtitleOnly(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + if !unit3d.ContainsRuleValue(unit3d.NormalizeRuleValues(meta.Release.Language), []string{"spanish", "es", "spa"}) { + return []api.RuleFailure{trackers.NewRuleFailure( + "spanish_track_required", + "TTR requires at least one Spanish audio or subtitle track.", + api.RuleDispositionStrict, + )}, nil + } + return nil, nil +} diff --git a/internal/trackers/impl/unit3d/sites/ulcx/banned_groups.go b/internal/trackers/impl/unit3d/sites/ulcx/banned_groups.go new file mode 100644 index 00000000..96568bde --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ulcx/banned_groups.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ulcx + +// BannedGroups returns ULCX's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "4K4U", + "AROMA", + "d3g", + "EDGE2020", + "EMBER", + "FGT", + "FnP", + "FRDS", + "Grym", + "Hi10", + "iAHD", + "INFINITY", + "ION10", + "iVy", + "Judas", + "LAMA", + "MeGusta", + "NAHOM", + "Niblets", + "nikt0", + "NuBz", + "OFT", + "QxR", + "Ralphy", + "RARBG", + "Sicario", + "SM737", + "SPDVD", + "SWTYBLZ", + "TAoE", + "TGx", + "Tigole", + "TSP", + "TSPxL", + "VXT", + "Vyndros", + "Will1869", + "x0r", + "YIFY", + "Alcaide_Kira", + "PHOCiS", + "HDT", + "SPx", + "seedpool", + } +} diff --git a/internal/trackers/impl/unit3d/sites/ulcx/profile.go b/internal/trackers/impl/unit3d/sites/ulcx/profile.go new file mode 100644 index 00000000..d65c4dc4 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ulcx/profile.go @@ -0,0 +1,41 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ulcx + +import ( + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns ULCX's WEB-DL hybrid-name normalization, size-variance dupe +// policy, tracker-owned rules, and banned groups. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "ULCX", + BaseURL: "https://upload.cx", + Rules: Rules(), + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + }, + DupePolicy: &trackers.DupePolicy{ + AllowSizeVariance1080: true, + }, + } +} +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + if strings.EqualFold(strings.TrimSpace(meta.Type), "WEBDL") && + (strings.Contains(strings.ToLower(strings.TrimSpace(meta.Edition)), "hybrid") || meta.WebDV) { + name = strings.Replace(name, "Hybrid ", "", 1) + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} diff --git a/internal/trackers/impl/unit3d/sites/ulcx/profile_test.go b/internal/trackers/impl/unit3d/sites/ulcx/profile_test.go new file mode 100644 index 00000000..6590c4e6 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ulcx/profile_test.go @@ -0,0 +1,21 @@ +package ulcx + +import ( + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildNameRemovesHybridFromWebDV(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Example Release 2026 Hybrid 1080p WEB-DL DDP5.1 DV H.265-GRP", + Type: "WEBDL", + Edition: "Hybrid", + WebDV: true, + } + if got := Profile().Site.BuildName(meta, config.TrackerConfig{}); strings.Contains(got, "Hybrid") { + t.Fatalf("name = %q", got) + } +} diff --git a/internal/trackers/impl/unit3d/sites/ulcx/rules.go b/internal/trackers/impl/unit3d/sites/ulcx/rules.go new file mode 100644 index 00000000..127dae7a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/ulcx/rules.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package ulcx + +import ( + "context" + "fmt" + "strings" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Rules strictly enforces valid MediaInfo settings, the DVDRip prohibition, +// HEVC eligibility, encode/HDTV resolution, and language requirements. Concert +// failures remain waivable. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{ + RequireValidMISetting: true, + BlockDVDRip: true, + Language: &trackers.LanguageRule{ + Languages: []string{"english", "en", "eng"}, + RequireAudio: true, + RequireSubs: true, + ApplyIfNonDisc: true, + }, + Check: checkRules, + } +} + +func checkRules(ctx context.Context, meta api.RuleSubject, _ api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("context canceled: %w", err) + } + failures := make([]api.RuleFailure, 0, 3) + if unit3d.ContainsRuleValue(unit3d.RuleKeywords(meta), []string{"concert"}) { + failures = append(failures, trackers.NewRuleFailure("block_concert", "Concerts not allowed at ULCX.", api.RuleDispositionWaivable)) + } + resolution := unit3d.RuleResolution(meta) + if strings.EqualFold(strings.TrimSpace(meta.VideoCodec), "HEVC") && resolution != "2160p" && !unit3d.Animation(meta) && !unit3d.Anime(meta) { + failures = append(failures, trackers.NewRuleFailure( + "hevc_resolution_2160p", + "This content might not fit HEVC rules for ULCX.", + api.RuleDispositionStrict, + )) + } + typeValue := unit3d.RuleType(meta) + if (typeValue == "ENCODE" || typeValue == "HDTV") && unit3d.ResolutionBelow(resolution, "720p") { + failures = append(failures, trackers.NewRuleFailure( + "encode_min_resolution", + "Encodes must be at least 720p resolution for ULCX.", + api.RuleDispositionStrict, + )) + } + if typeValue == "DVDRIP" { + failures = append(failures, trackers.NewRuleFailure("block_dvdrip", "DVDRIPs are not allowed for ULCX.", api.RuleDispositionWaivable)) + } + return failures, nil +} diff --git a/internal/trackers/impl/unit3d/sites/utp/profile.go b/internal/trackers/impl/unit3d/sites/utp/profile.go new file mode 100644 index 00000000..90391758 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/utp/profile.go @@ -0,0 +1,44 @@ +package utp + +import ( + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns UTP's type and resolution mappings, including fallback IDs +// for unknown values. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "UTP", + BaseURL: "https://utp.to", + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + ResolveResolutionID: resolutionID, + }, + } +} +func typeID(meta api.UploadSubject) string { + value := map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + }[unit3d.InferType(meta)] + if value == "" { + return "3" + } + return value +} +func resolutionID(meta api.UploadSubject) string { + if value, ok := map[string]string{ + "4320p": "1", + "2160p": "2", + "1080p": "3", + "1080i": "4", + }[unit3d.Resolution(meta)]; ok { + return value + } + return "11" +} diff --git a/internal/trackers/impl/unit3d/sites/yus/banned_groups.go b/internal/trackers/impl/unit3d/sites/yus/banned_groups.go new file mode 100644 index 00000000..b43553b8 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/yus/banned_groups.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package yus + +// BannedGroups returns YUS's static release-group blacklist. +func BannedGroups() []string { + return []string{ + "ADDICTION", + "B3LLUM", + "BANDOLEROS", + "BigEasy", + "CINEMAXIS", + "D3US", + "d3g", + "DUMMESCHWEDEN", + "FGT", + "GRANiTEN", + "KiNGDOM", + "Lama", + "MeGusta", + "MezRips", + "mHD", + "mRS", + "msd", + "NeXus", + "NhaNc3", + "nHD", + "NorTekst", + "NORViNE", + "PANDEMONiUM", + "PiTBULL", + "RAPiDCOWS", + "RARBG", + "Radarr", + "RCDiVX", + "RDN", + "ROCKETRACCOON", + "SANTi", + "SHOWTiME", + "SOOSi", + "SUXWIC", + "TOXVIO", + "TWA", + "VXT", + "Will1869", + "x0r", + "XS", + "YIFY", + "YOLAND", + "YTS", + "ZKBL", + "ZmN", + "ZMNT", + } +} diff --git a/internal/trackers/impl/unit3d/sites/yus/profile.go b/internal/trackers/impl/unit3d/sites/yus/profile.go new file mode 100644 index 00000000..0c8e6605 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/yus/profile.go @@ -0,0 +1,28 @@ +package yus + +import ( + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns YUS's site-specific type mapping and banned groups. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "YUS", + BaseURL: "https://yu-scene.net", + BannedGroups: BannedGroups(), + Site: unit3d.SiteProfile{ + ResolveTypeID: typeID, + }, + } +} +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "17", + "REMUX": "2", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + "ENCODE": "3", + }[unit3d.InferType(meta)] +} diff --git a/internal/trackers/impl/unit3d/sites/znth/name.go b/internal/trackers/impl/unit3d/sites/znth/name.go new file mode 100644 index 00000000..941f697a --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/znth/name.go @@ -0,0 +1,225 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package znth + +import ( + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// buildZNTHName applies ZNTH release-name policy before upload. +// TV names drop episode-title text when it appears before the resolution, while +// non-TV names prefer the IMDb year when it disagrees with the parsed release year. +func buildName(meta api.UploadSubject, _ config.TrackerConfig) string { + name := strings.TrimSpace(meta.ReleaseName) + if name == "" { + name = strings.TrimSpace(meta.ReleaseNameNoTag) + } + category := unit3d.Category(meta) + if category == "TV" && strings.TrimSpace(meta.EpisodeTitle) != "" { + resolution := unit3d.Resolution(meta) + if resolution != "" { + name = replaceZNTHEpisodeTitle(name, meta.EpisodeTitle, resolution) + } + } + + if category == "MOVIE" { + imdbYear := 0 + if meta.ProviderMetadata.IMDB != nil { + imdbYear = meta.ProviderMetadata.IMDB.Year + } + year := meta.Release.Year + if imdbYear > 0 && year > 0 && imdbYear != year { + name = replaceZNTHMovieYear(name, meta, year, imdbYear) + } + } + return strings.TrimSpace(strings.Join(strings.Fields(name), " ")) +} + +// replaceZNTHEpisodeTitle removes the episode-title segment only when its +// normalized text appears immediately before a matching resolution token. +func replaceZNTHEpisodeTitle(name string, episodeTitle string, resolution string) string { + normalizedTitle := normalizeZNTHAlphaNum(episodeTitle) + if normalizedTitle == "" { + return name + } + + for _, resolutionStart := range findZNTHTokenIndexes(name, resolution) { + titleStart, ok := findZNTHTitleStartBefore(name[:resolutionStart], normalizedTitle) + if !ok { + continue + } + return name[:titleStart] + name[resolutionStart:] + } + return name +} + +// findZNTHTitleStartBefore returns the byte offset of the trailing segment in +// prefix whose alphanumeric-normalized text matches normalizedTitle. +func findZNTHTitleStartBefore(prefix string, normalizedTitle string) (int, bool) { + candidates := []int{0} + for i, r := range prefix { + if !isZNTHAlphaNum(r) { + candidates = append(candidates, i+len(string(r))) + } + } + + for i := len(candidates) - 1; i >= 0; i-- { + start := candidates[i] + if normalizeZNTHAlphaNum(prefix[start:]) == normalizedTitle { + return start, true + } + } + return 0, false +} + +// replaceZNTHMovieYear replaces the parsed release-year token before the first +// matching resolution token, or before a trailing metadata release-group suffix +// when no resolution is known. +func replaceZNTHMovieYear(name string, meta api.UploadSubject, year int, imdbYear int) string { + yearToken := strconv.Itoa(year) + yearIndexes := findZNTHTokenIndexes(name, yearToken) + if len(yearIndexes) == 0 { + return name + } + + searchEnd := len(name) + if resolution := unit3d.Resolution(meta); resolution != "" { + resolutionIndexes := findZNTHTokenIndexes(name, resolution) + if len(resolutionIndexes) > 0 { + searchEnd = resolutionIndexes[0] + } + } else if groupStart, ok := findZNTHReleaseGroupStart(name, meta.Release.Group); ok { + searchEnd = groupStart + } + + replaceStart := -1 + for _, yearStart := range yearIndexes { + if yearStart < searchEnd { + replaceStart = yearStart + } + } + if replaceStart == -1 { + return name + } + + replacement := strconv.Itoa(imdbYear) + return name[:replaceStart] + replacement + name[replaceStart+len(yearToken):] +} + +// findZNTHTokenIndexes returns original-string byte offsets for +// case-insensitive token matches bounded by non-alphanumeric ZNTH separators. +func findZNTHTokenIndexes(value string, token string) []int { + token = strings.TrimSpace(token) + if token == "" { + return nil + } + + tokenRunes := utf8.RuneCountInString(token) + indexes := []int{} + for start := range value { + end, ok := endAfterZNTHRunes(value, start, tokenRunes) + if !ok { + break + } + if strings.EqualFold(value[start:end], token) && hasZNTHTokenBoundaries(value, start, end) { + indexes = append(indexes, start) + } + } + return indexes +} + +// findZNTHReleaseGroupStart returns the byte offset of a trailing "-group" +// suffix only when group is a real parsed release group. +func findZNTHReleaseGroupStart(name string, group string) (int, bool) { + group = strings.TrimSpace(group) + if group == "" || unit3d.IsNoGroupTag(group) { + return 0, false + } + + trimmedName := strings.TrimRightFunc(name, unicode.IsSpace) + groupStart, ok := foldSuffixStart(trimmedName, group) + if !ok { + return 0, false + } + + boundary := groupStart + for boundary > 0 { + r, size := utf8.DecodeLastRuneInString(trimmedName[:boundary]) + if !unicode.IsSpace(r) { + break + } + boundary -= size + } + if boundary > 0 && trimmedName[boundary-1] == '-' { + return boundary - 1, true + } + return 0, false +} + +// foldSuffixStart returns the byte offset where suffix starts when value ends +// with suffix under Unicode case folding. +func foldSuffixStart(value string, suffix string) (int, bool) { + start := len(value) + for range suffix { + if start == 0 { + return 0, false + } + _, size := utf8.DecodeLastRuneInString(value[:start]) + start -= size + } + return start, strings.EqualFold(value[start:], suffix) +} + +// endAfterZNTHRunes returns the byte offset after count runes from start. +func endAfterZNTHRunes(value string, start int, count int) (int, bool) { + end := start + for range count { + if end >= len(value) { + return 0, false + } + _, size := utf8.DecodeRuneInString(value[end:]) + end += size + } + return end, true +} + +// hasZNTHTokenBoundaries reports whether start and end are outside adjacent +// letters or digits in value. +func hasZNTHTokenBoundaries(value string, start int, end int) bool { + if start > 0 { + r, _ := utf8.DecodeLastRuneInString(value[:start]) + if isZNTHAlphaNum(r) { + return false + } + } + if end < len(value) { + r, _ := utf8.DecodeRuneInString(value[end:]) + if isZNTHAlphaNum(r) { + return false + } + } + return true +} + +// normalizeZNTHAlphaNum lowercases value and drops every non-alphanumeric rune. +func normalizeZNTHAlphaNum(value string) string { + var b strings.Builder + for _, r := range value { + if isZNTHAlphaNum(r) { + b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() +} + +func isZNTHAlphaNum(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} diff --git a/internal/trackers/impl/unit3d/sites/znth/name_test.go b/internal/trackers/impl/unit3d/sites/znth/name_test.go new file mode 100644 index 00000000..f03def17 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/znth/name_test.go @@ -0,0 +1,235 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package znth + +import ( + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestBuildZNTHNameTV(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Show.S01E01.Episode.Title.1080p.WEB-DL-GRP", + EpisodeTitle: "Episode Title", + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Show.S01E01.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameMovieYearMismatch(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Movie.2024.1080p.WEB-DL-GRP", + Release: api.ReleaseInfo{Year: 2024}, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + }, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Movie.2025.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameBlankCategoryDoesNotInferTVCategory(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Show.1x01.Episode.Title.1080p.WEB-DL-GRP", + EpisodeTitle: "Episode Title", + SeasonInt: 1, + EpisodeInt: 1, + Release: api.ReleaseInfo{ + Category: "TV", + Resolution: "1080p", + }, + } + + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Show.1x01.Episode.Title.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameUnknownCategoryDoesNotInferTVCategory(t *testing.T) { + tests := []struct { + name string + meta api.UploadSubject + }{ + { + name: "external unknown", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "animation"}, + }, + }, + { + name: "identity absent", + meta: api.UploadSubject{}, + }, + { + name: "external unknown ignores release tv", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "animation"}, + }, + }, + } + + for _, tc := range tests { + tc.meta.ReleaseName = "Show.S01E01.2024.Episode.Title.1080p.WEB-DL-GRP" + tc.meta.EpisodeTitle = "Episode Title" + tc.meta.SeasonInt = 1 + tc.meta.EpisodeInt = 1 + tc.meta.Release = api.ReleaseInfo{ + Category: "TV", + Resolution: "1080p", + Year: 2024, + } + tc.meta.ProviderMetadata = api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + } + + got := Profile().Site.BuildName(tc.meta, config.TrackerConfig{}) + expected := "Show.S01E01.2024.Episode.Title.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("%s: expected %q, got %q", tc.name, expected, got) + } + } +} + +func TestBuildZNTHNameExplicitMoviePreservesMovieBranchOverParsedTV(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Show.S01E01.2024.Episode.Title.1080p.WEB-DL-GRP", + EpisodeTitle: "Episode Title", + Identity: api.ExternalIdentity{Category: "MOVIE"}, + SeasonInt: 1, + EpisodeInt: 1, + Release: api.ReleaseInfo{ + Category: "TV", + Resolution: "1080p", + Year: 2024, + }, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + }, + } + + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Show.S01E01.2025.Episode.Title.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameBlankCategoryDoesNotInferMovieCategory(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Example.Movie.2026.1080p.WEB-DL-GRP", + Release: api.ReleaseInfo{ + Category: "MOVIE", + Year: 2026, + }, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2027}, + }, + } + + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Example.Movie.2026.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameMovieYearMismatchNoResolutionHyphenatedTitle(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Movie - Part One 2024", + Release: api.ReleaseInfo{Year: 2024}, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + }, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Movie - Part One 2025" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameMovieYearMismatchNoResolutionGroupSuffix(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "Movie.Title.2024-GRP2024", + Release: api.ReleaseInfo{Year: 2024, Group: "GRP2024"}, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + }, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "Movie.Title.2025-GRP2024" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameTVUnicodePrefix(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "\u212aShow.S01E01.Episode.Title.1080p.WEB-DL-GRP", + EpisodeTitle: "Episode Title", + Identity: api.ExternalIdentity{Category: "TV"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "\u212aShow.S01E01.1080p.WEB-DL-GRP" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestBuildZNTHNameMovieYearMismatchUnicodeTitle(t *testing.T) { + meta := api.UploadSubject{ + ReleaseName: "\u212aMovie.2024", + Release: api.ReleaseInfo{Year: 2024}, + Identity: api.ExternalIdentity{Category: "MOVIE"}, + ProviderMetadata: api.SourceScopedMetadata{ + IMDB: &api.IMDBMetadata{Year: 2025}, + }, + } + got := Profile().Site.BuildName(meta, config.TrackerConfig{}) + expected := "\u212aMovie.2025" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestFindZNTHTokenIndexesUnicodeBoundaries(t *testing.T) { + got := findZNTHTokenIndexes("Title.\u212a.1080p.Source", "1080p") + expected := len("Title.\u212a.") + if len(got) != 1 || got[0] != expected { + t.Fatalf("expected index %d, got %#v", expected, got) + } + + if got := findZNTHTokenIndexes("Title.\u06611080p.Source", "1080p"); len(got) != 0 { + t.Fatalf("expected adjacent Unicode digit prefix to reject token, got %#v", got) + } + if got := findZNTHTokenIndexes("Title.1080p\u0661.Source", "1080p"); len(got) != 0 { + t.Fatalf("expected adjacent Unicode digit suffix to reject token, got %#v", got) + } +} + +func TestZNTHEmptyTokenInputs(t *testing.T) { + name := "Show.S01E01.1080p.WEB-DL-GRP" + if got := replaceZNTHEpisodeTitle(name, "", "1080p"); got != name { + t.Fatalf("expected empty episode title to leave name unchanged, got %q", got) + } + if got := findZNTHTokenIndexes(name, " "); got != nil { + t.Fatalf("expected empty token indexes to be nil, got %#v", got) + } +} diff --git a/internal/trackers/impl/unit3d/sites/znth/profile.go b/internal/trackers/impl/unit3d/sites/znth/profile.go new file mode 100644 index 00000000..6fd23539 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/znth/profile.go @@ -0,0 +1,32 @@ +package znth + +import ( + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/pkg/api" +) + +// Profile returns ZNTH's tracker-specific name policy and type mapping, +// including its dedicated DVDRip type ID. +func Profile() unit3d.Profile { + return unit3d.Profile{ + Name: "ZNTH", + BaseURL: "https://znth.cx", + Rules: Rules(), + Site: unit3d.SiteProfile{ + BuildName: buildName, + ResolveTypeID: typeID, + }, + } +} + +func typeID(meta api.UploadSubject) string { + return map[string]string{ + "DISC": "1", + "REMUX": "2", + "ENCODE": "3", + "DVDRIP": "11", + "WEBDL": "4", + "WEBRIP": "5", + "HDTV": "6", + }[unit3d.InferType(meta)] +} diff --git a/internal/trackers/impl/unit3d/sites/znth/rules.go b/internal/trackers/impl/unit3d/sites/znth/rules.go new file mode 100644 index 00000000..e66dd887 --- /dev/null +++ b/internal/trackers/impl/unit3d/sites/znth/rules.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package znth + +import "github.com/autobrr/upbrr/internal/trackers" + +// Rules returns ZNTH's waivable adult-content prohibition. +func Rules() *trackers.RuleSet { + return &trackers.RuleSet{BlockAdult: true, AdultMessage: "Porn/xxx is not allowed at ZNTH."} +} diff --git a/internal/trackers/impl/unit3d/stc.go b/internal/trackers/impl/unit3d/stc.go deleted file mode 100644 index 3a9e0415..00000000 --- a/internal/trackers/impl/unit3d/stc.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteSTCProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DSTCTypeID} -} - -func resolveUnit3DSTCTypeID(meta api.PreparedMetadata) string { - typeValue := inferUnit3DType(meta) - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - "ENCODE": "3", - } - if meta.TVPack { - isWeb := typeValue == "WEBDL" || typeValue == "WEBRIP" - sd := isSDResolution(resolveResolution(meta)) - if sd { - if isWeb { - return "14" - } - return "17" - } - if isWeb { - return "13" - } - return "18" - } - return mapping[typeValue] -} diff --git a/internal/trackers/impl/unit3d/tik.go b/internal/trackers/impl/unit3d/tik.go deleted file mode 100644 index 529c5b4e..00000000 --- a/internal/trackers/impl/unit3d/tik.go +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteTIKProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DTIKTypeID, - resolveCategoryID: resolveUnit3DTIKCategoryID, - } -} - -func resolveUnit3DTIKTypeID(meta api.PreparedMetadata) string { - discType := resolveTIKDiscType(meta) - mapping := map[string]string{ - "CUSTOM": "1", - "BD100": "3", - "BD66": "4", - "BD50": "5", - "BD25": "6", - "NTSC DVD9": "7", - "NTSC DVD5": "8", - "PAL DVD9": "9", - "PAL DVD5": "10", - "3D": "11", - } - return mapping[discType] -} - -func resolveTIKDiscType(meta api.PreparedMetadata) string { - if meta.TrackerSiteOverrides.TIK.DiscType != nil { - return strings.ToUpper(strings.TrimSpace(*meta.TrackerSiteOverrides.TIK.DiscType)) - } - if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { - return "3D" - } - - releaseName := strings.ToUpper(strings.TrimSpace(meta.ReleaseName)) - source := strings.ToUpper(strings.TrimSpace(meta.Source)) - if source == "" { - source = strings.ToUpper(strings.TrimSpace(meta.Release.Source)) - } - combined := releaseName + " " + source - - for _, marker := range []string{"BD100", "BD66", "BD50", "BD25"} { - if strings.Contains(combined, marker) { - return marker - } - } - - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") || strings.Contains(combined, "DVD") { - if strings.Contains(combined, "PAL") { - if strings.Contains(combined, "DVD5") { - return "PAL DVD5" - } - return "PAL DVD9" - } - if strings.Contains(combined, "NTSC") { - if strings.Contains(combined, "DVD5") { - return "NTSC DVD5" - } - return "NTSC DVD9" - } - if strings.Contains(combined, "DVD5") { - return "NTSC DVD5" - } - return "NTSC DVD9" - } - - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { - return "CUSTOM" - } - - return "" -} - -func resolveUnit3DTIKCategoryID(meta api.PreparedMetadata) string { - category := resolveUnit3DCategory(meta) - foreign := isTIKForeign(meta) - opera := isTIKOpera(meta) - asian := isTIKAsian(meta) - - if strings.EqualFold(category, "MOVIE") { - if foreign { - return "3" - } - if opera { - return "5" - } - if asian { - return "6" - } - return "1" - } - - if strings.EqualFold(category, "TV") { - if foreign { - return "4" - } - if opera { - return "5" - } - return "2" - } - - return resolveUnit3DCategoryID(meta) -} - -func isTIKForeign(meta api.PreparedMetadata) bool { - if meta.TrackerSiteOverrides.TIK.Foreign != nil { - return *meta.TrackerSiteOverrides.TIK.Foreign - } - if meta.ExternalMetadata.TMDB != nil { - original := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.TMDB.OriginalLanguage)) - if original != "" && original != "en" { - return true - } - } - return !hasEnglishLanguage(meta.AudioLanguages) && !hasEnglishLanguage(meta.SubtitleLanguages) -} - -func isTIKOpera(meta api.PreparedMetadata) bool { - if meta.TrackerSiteOverrides.TIK.Opera != nil { - return *meta.TrackerSiteOverrides.TIK.Opera - } - candidates := strings.ToLower(strings.Join([]string{ - strings.TrimSpace(meta.Release.Genre), - resolveTMDBGenres(meta), - resolveIMDBGenres(meta), - resolveKeywords(meta), - }, ",")) - return strings.Contains(candidates, "opera") || strings.Contains(candidates, "musical") -} - -func isTIKAsian(meta api.PreparedMetadata) bool { - if meta.TrackerSiteOverrides.TIK.Asian != nil { - return *meta.TrackerSiteOverrides.TIK.Asian - } - if meta.ExternalMetadata.TMDB == nil { - return false - } - - asianCountries := map[string]bool{ - "JP": true, "KR": true, "CN": true, "HK": true, "TW": true, - "TH": true, "VN": true, "IN": true, "ID": true, "MY": true, - "PH": true, "SG": true, - } - for _, country := range meta.ExternalMetadata.TMDB.OriginCountry { - if asianCountries[strings.ToUpper(strings.TrimSpace(country))] { - return true - } - } - - asianLanguages := map[string]bool{ - "ja": true, "ko": true, "zh": true, "th": true, "vi": true, - "hi": true, "ta": true, "te": true, "ml": true, "id": true, - "ms": true, - } - original := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.TMDB.OriginalLanguage)) - return asianLanguages[original] -} diff --git a/internal/trackers/impl/unit3d/tlz.go b/internal/trackers/impl/unit3d/tlz.go deleted file mode 100644 index 44aa3862..00000000 --- a/internal/trackers/impl/unit3d/tlz.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteTLZProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DTLZTypeID} -} - -func resolveUnit3DTLZTypeID(meta api.PreparedMetadata) string { - if strings.EqualFold(resolveUnit3DCategory(meta), "MOVIE") { - return "1" - } - if meta.TVPack { - return "4" - } - return "3" -} diff --git a/internal/trackers/impl/unit3d/tos.go b/internal/trackers/impl/unit3d/tos.go deleted file mode 100644 index f9a26d70..00000000 --- a/internal/trackers/impl/unit3d/tos.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import ( - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -func siteTOSProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DTOSTypeID, - resolveCategoryID: resolveUnit3DTOSCategoryID, - } -} - -func resolveUnit3DTOSCategoryID(meta api.PreparedMetadata) string { - tag := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(meta.Tag, "-"))) - isSubFrench := strings.Contains(tag, "vostfr") || strings.Contains(tag, "subfrench") - category := resolveUnit3DCategory(meta) - if strings.EqualFold(category, "TV") { - if meta.TVPack { - if isSubFrench { - return "9" - } - return "8" - } - if isSubFrench { - return "7" - } - return "2" - } - if isSubFrench { - return "6" - } - return "1" -} - -func resolveUnit3DTOSTypeID(meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(meta.DiscType), "DVD") { - return "7" - } - if strings.EqualFold(strings.TrimSpace(meta.Is3D), "3D") { - return "8" - } - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "ENCODE": "3", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - } - return mapping[inferUnit3DType(meta)] -} diff --git a/internal/trackers/impl/unit3d/upload.go b/internal/trackers/impl/unit3d/upload.go index 4fc1a7f3..ba81e59a 100644 --- a/internal/trackers/impl/unit3d/upload.go +++ b/internal/trackers/impl/unit3d/upload.go @@ -20,14 +20,13 @@ import ( "strings" "time" - "github.com/autobrr/upbrr/internal/paths" + descriptionunit3d "github.com/autobrr/upbrr/internal/description/unit3d" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/redaction" "github.com/autobrr/upbrr/internal/services/db" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" - "github.com/autobrr/upbrr/internal/trackerdata" "github.com/autobrr/upbrr/internal/trackers" + trackerdata "github.com/autobrr/upbrr/internal/trackers/data" "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" - "github.com/autobrr/upbrr/internal/trackers/unit3dmeta" "github.com/autobrr/upbrr/pkg/api" ) @@ -37,124 +36,27 @@ type unit3dUploadResponse struct { Data string `json:"data"` } -func uploadUnit3D(ctx context.Context, req trackers.UploadRequest) (api.UploadSummary, error) { - trackerName := strings.ToUpper(strings.TrimSpace(req.Tracker)) - logger := req.Logger - if logger == nil { - logger = api.NopLogger{} - } - - logger.Infof("trackers: starting upload to %s for release: %s", trackerName, req.Meta.ReleaseName) - - apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) - if apiKey == "" { - err := fmt.Errorf("trackers: %s missing api_key", trackerName) - logger.Errorf("trackers: %s upload aborted: %v", trackerName, err) - return api.UploadSummary{}, err - } - if !req.Meta.ValidMediaInfoSettings { - err := fmt.Errorf("trackers: %s mediainfo missing required fields", trackerName) - logger.Errorf("trackers: %s upload aborted: %v", trackerName, err) - return api.UploadSummary{}, err - } - - baseURL, uploadURL := resolveUnit3DURLs(trackerName, req.TrackerConfig.URL) +// submitUnit3DUpload sends an already serialized multipart payload and maps a +// successful Unit3D response to one upload, with an artifact only when the +// response identifies a download URL. +func submitUnit3DUpload( + ctx context.Context, + trackerName string, + releaseName string, + apiKey string, + baseURL string, + uploadURL string, + contentType string, + payload string, + logger api.Logger, +) (api.UploadSummary, error) { + logger.Infof("trackers: starting upload to %s for release: %s", trackerName, releaseName) logger.Debugf("trackers: %s upload URL: %s", trackerName, uploadURL) - originalName := strings.TrimSpace(req.Meta.ReleaseName) - if originalName == "" { - originalName = strings.TrimSpace(req.Meta.ReleaseNameNoTag) - } - name := buildUnit3DName(trackerName, req.Meta, req.TrackerConfig) - if name != originalName { - logger.Infof("trackers: %s name formatting applied", trackerName) - logger.Infof(" Original: %s", originalName) - logger.Infof(" Formatted: %s", name) - } else { - logger.Debugf("trackers: %s using original name: %s", trackerName, name) - } - - var err error - assets := trackers.DescriptionAssets{} - if req.Assets != nil { - assets = *req.Assets - } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, logger) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return api.UploadSummary{}, fmt.Errorf("trackers: %w", err) - } - logger.Warnf("trackers: %s description assets failed: %v", trackerName, err) - assets = trackers.DescriptionAssets{} - } - } - description := strings.TrimSpace(assets.Description) - if !assets.Final { - description, err = buildUnit3DDescription(ctx, trackerName, req.Meta, req.AppConfig, req.TrackerConfig, logger, assets.Description, assets.MenuImages, assets.Screenshots) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return api.UploadSummary{}, err - } - logger.Warnf("trackers: %s description build failed: %v", trackerName, err) - description = "" - } - } - description = ensureUnit3DDVDVOBDescription(description, req.Meta) - mediainfo, bdinfo, err := loadUnit3DMedia(req.Meta, req.AppConfig.MainSettings.DBPath, logger) - if err != nil { - logger.Errorf("trackers: %s failed to load media info: %v", trackerName, err) - return api.UploadSummary{}, err - } - - data, err := buildUnit3DData(req, name, description, mediainfo, bdinfo) - if err != nil { - logger.Errorf("trackers: %s failed to build upload data: %v", trackerName, err) - return api.UploadSummary{}, err - } - if message, err := validateUnit3DTVPayloadMetadata(trackerName, req.Meta, data); err != nil { - logger.Warnf("trackers: %s %s", trackerName, message) - return api.UploadSummary{}, err - } - category := resolveUnit3DCategory(req.Meta) - _, hasTVDB := data["tvdb"] - _, hasSeason := data["season_number"] - _, hasEpisode := data["episode_number"] - logger.Debugf( - "trackers: %s payload mapping category=%s category_id=%s type_id=%s meta_type=%q release_type=%q tvdb=%t season_number=%t episode_number=%t", - trackerName, - category, - data["category_id"], - data["type_id"], - strings.TrimSpace(req.Meta.Type), - strings.TrimSpace(req.Meta.Release.Type), - hasTVDB, - hasSeason, - hasEpisode, - ) - data["mod_queue_opt_in"] = boolFlag(req.TrackerConfig.ModQ) - - if req.TrackerConfig.Exclusive { - data["exclusive"] = "1" - logger.Debugf("trackers: %s marking as exclusive release", trackerName) - } - if req.TrackerConfig.Anon { - logger.Debugf("trackers: %s uploading anonymously", trackerName) - } - if req.TrackerConfig.ModQ { - logger.Debugf("trackers: %s opted into moderation queue", trackerName) - } - - logger.Tracef("trackers: %s building multipart form payload", trackerName) - payload, contentType, err := buildMultipartPayload(req, data, logger) - if err != nil { - logger.Errorf("trackers: %s failed to build payload: %v", trackerName, err) - return api.UploadSummary{}, err - } - reqCtx, cancel := context.WithTimeout(ctx, 40*time.Second) defer cancel() - httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, uploadURL, payload) + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, uploadURL, strings.NewReader(payload)) if err != nil { logger.Errorf("trackers: %s failed to create HTTP request: %v", trackerName, err) return api.UploadSummary{}, fmt.Errorf("trackers: %s build upload request: %w", trackerName, err) @@ -174,7 +76,11 @@ func uploadUnit3D(ctx context.Context, req trackers.UploadRequest) (api.UploadSu logger.Debugf("trackers: %s received HTTP %d response", trackerName, resp.StatusCode) - body, bodyPreview, err := commonhttp.ReadUploadResponseBody(resp, resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, commonhttp.DefaultResponsePreviewBytes) + body, bodyPreview, err := commonhttp.ReadUploadResponseBody( + resp, + resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, + commonhttp.DefaultResponsePreviewBytes, + ) if err != nil { logger.Errorf("trackers: %s failed to read response body: %v", trackerName, err) return api.UploadSummary{}, fmt.Errorf("trackers: %s read response body: %w", trackerName, err) @@ -229,22 +135,56 @@ func uploadUnit3D(ctx context.Context, req trackers.UploadRequest) (api.UploadSu return summary, nil } -func defaultUnit3DBaseURL(tracker string) string { - if baseURL, ok := unit3dmeta.BaseURL(tracker); ok { - return baseURL +// prepareUnit3DUpload builds one preview for every intent. Upload intent +// serializes its files during preparation and captures the payload, endpoint, +// API key, and logger so submission does not reread mutable prepared inputs. +func prepareUnit3DUpload( + ctx context.Context, + req trackers.PreparationInput, + configuredBaseURL string, + profiles ...SiteProfile, +) (trackers.PreparedOperation, error) { + preview, err := buildUploadDryRunUnit3D(ctx, req, configuredBaseURL, profiles...) + if err != nil { + return trackers.PreparedOperation{}, err + } + if req.Intent != trackers.PreparationIntentUpload { + return trackers.NewPreparedOperation(preview, nil, nil), nil + } + if !strings.EqualFold(preview.Status, "ready") { + return trackers.PreparedOperation{}, fmt.Errorf("trackers: %s upload blocked: %s", preview.Tracker, preview.Message) + } + + logger := req.Logger + if logger == nil { + logger = api.NopLogger{} } - baseURL, ok := unit3dmeta.BaseURL(unit3dmeta.DefaultTracker()) - if ok { - return baseURL + torrentPath := preparedFilePath(preview.Files, "torrent") + nfoPath := preparedFilePath(preview.Files, "nfo") + payload, contentType, err := buildMultipartPayload(preview.Payload, torrentPath, nfoPath, logger) + if err != nil { + return trackers.PreparedOperation{}, err + } + baseURL, uploadURL := resolveUnit3DURLs(configuredBaseURL) + trackerName := preview.Tracker + releaseName := preview.ReleaseName + apiKey := strings.TrimSpace(req.TrackerConfig.APIKey) + return trackers.NewPreparedOperation(preview, func(submitCtx context.Context) (api.UploadSummary, error) { + return submitUnit3DUpload(submitCtx, trackerName, releaseName, apiKey, baseURL, uploadURL, contentType, payload, logger) + }, nil), nil +} + +func preparedFilePath(files []api.TrackerDryRunFile, field string) string { + for _, file := range files { + if file.Field == field && file.Present { + return file.Path + } } return "" } -func resolveUnit3DURLs(tracker string, configuredBaseURL string) (string, string) { +func resolveUnit3DURLs(configuredBaseURL string) (string, string) { baseURL := strings.TrimSpace(configuredBaseURL) - if baseURL == "" { - baseURL = defaultUnit3DBaseURL(tracker) - } return baseURL, strings.TrimRight(baseURL, "/") + "/api/torrents/upload" } @@ -343,7 +283,13 @@ func isNumericID(value string) bool { // files, and endpoint that would be used locally. TV payloads with zero-valued // canonical season or episode metadata are returned as blocked because the // payload no longer satisfies upload prerequisites. -func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (api.TrackerDryRunEntry, error) { +func buildUploadDryRunUnit3D( + ctx context.Context, + req trackers.PreparationInput, + configuredBaseURL string, + profiles ...SiteProfile, +) (api.TrackerDryRunEntry, error) { + profile := firstSiteProfile(profiles) select { case <-ctx.Done(): return api.TrackerDryRunEntry{}, fmt.Errorf("context canceled: %w", ctx.Err()) @@ -360,17 +306,17 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a if apiKey == "" { return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: %s missing api_key", trackerName) } - if !req.Meta.ValidMediaInfoSettings { + if !req.Meta.Assessments.EncodeSettingsRequirementSatisfied() { return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: %s mediainfo missing required fields", trackerName) } - _, uploadURL := resolveUnit3DURLs(trackerName, req.TrackerConfig.URL) + _, uploadURL := resolveUnit3DURLs(configuredBaseURL) originalName := strings.TrimSpace(req.Meta.ReleaseName) if originalName == "" { originalName = strings.TrimSpace(req.Meta.ReleaseNameNoTag) } - name := buildUnit3DName(trackerName, req.Meta, req.TrackerConfig) + name := buildUnit3DName(trackerName, req.Meta, req.TrackerConfig, profile) if name != originalName { logger.Infof("trackers: %s dry-run name formatting applied", trackerName) } @@ -380,7 +326,7 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a if req.Assets != nil { assets = *req.Assets } else { - assets, err = trackers.ResolveDescriptionAssets(ctx, req.Tracker, req.Meta, req.Repo, logger) + assets, err = trackers.PreparedDescriptionAssets(req.Assets) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return api.TrackerDryRunEntry{}, fmt.Errorf("trackers: %w", err) @@ -391,7 +337,18 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a } description := strings.TrimSpace(assets.Description) if !assets.Final { - description, err = buildUnit3DDescription(ctx, trackerName, req.Meta, req.AppConfig, req.TrackerConfig, logger, assets.Description, assets.MenuImages, assets.Screenshots) + description, err = buildUnit3DDescription( + ctx, + trackerName, + req.Meta, + req.Runtime.DescriptionConfig(), + req.TrackerConfig, + logger, + assets.Description, + assets.MenuImages, + assets.Screenshots, + profile, + ) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return api.TrackerDryRunEntry{}, err @@ -401,12 +358,12 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a } description = ensureUnit3DDVDVOBDescription(description, req.Meta) - mediainfo, bdinfo, err := loadUnit3DMedia(req.Meta, req.AppConfig.MainSettings.DBPath, logger) + mediainfo, bdinfo, err := loadUnit3DMedia(req.Meta, req.Runtime.DBPath, logger) if err != nil { return api.TrackerDryRunEntry{}, err } - data, err := buildUnit3DData(req, name, description, mediainfo, bdinfo) + data, err := buildUnit3DData(req, name, description, mediainfo, bdinfo, profile) if err != nil { return api.TrackerDryRunEntry{}, err } @@ -415,11 +372,11 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a data["exclusive"] = "1" } - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath, logger) + torrentPath, err := resolveTorrentPath(req.Meta, req.Runtime.DBPath, logger) if err != nil { return api.TrackerDryRunEntry{}, err } - nfoPath := resolveNFOPath(req.Meta, req.AppConfig.MainSettings.DBPath) + nfoPath := resolveNFOPath(req.Meta, req.Runtime.DBPath) files := []api.TrackerDryRunFile{{ Field: "torrent", @@ -427,7 +384,11 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a Present: strings.TrimSpace(torrentPath) != "", }} if strings.TrimSpace(nfoPath) != "" { - files = append(files, api.TrackerDryRunFile{Field: "nfo", Path: nfoPath, Present: true}) + files = append(files, api.TrackerDryRunFile{ + Field: "nfo", + Path: nfoPath, + Present: true, + }) } message := "dry-run payload generated" @@ -435,6 +396,9 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a if metadataMessage, err := validateUnit3DTVPayloadMetadata(trackerName, req.Meta, data); err != nil { message += "; " + metadataMessage status = "blocked" + if req.Intent == trackers.PreparationIntentUpload { + logger.Warnf("trackers: %s %s", trackerName, metadataMessage) + } } return api.TrackerDryRunEntry{ @@ -450,7 +414,7 @@ func buildUploadDryRunUnit3D(ctx context.Context, req trackers.UploadRequest) (a }, nil } -func buildMultipartPayload(req trackers.UploadRequest, data map[string]string, logger api.Logger) (*strings.Reader, string, error) { +func buildMultipartPayload(data map[string]string, torrentPath string, nfoPath string, logger api.Logger) (string, string, error) { var builder strings.Builder writer := multipart.NewWriter(&builder) @@ -458,38 +422,31 @@ func buildMultipartPayload(req trackers.UploadRequest, data map[string]string, l for key, value := range data { if err := writer.WriteField(key, value); err != nil { _ = writer.Close() - return nil, "", fmt.Errorf("trackers: UNIT3D write multipart field %q: %w", key, err) + return "", "", fmt.Errorf("trackers: UNIT3D write multipart field %q: %w", key, err) } } - torrentPath, err := resolveTorrentPath(req.Meta, req.AppConfig.MainSettings.DBPath, logger) - if err != nil { - _ = writer.Close() - logger.Errorf("trackers: failed to resolve torrent path: %v", err) - return nil, "", err - } - logger.Debugf("trackers: attaching torrent file: %s", filepath.Base(torrentPath)) if err := addFile(writer, "torrent", torrentPath); err != nil { _ = writer.Close() - return nil, "", err + return "", "", err } - if nfoPath := resolveNFOPath(req.Meta, req.AppConfig.MainSettings.DBPath); nfoPath != "" { + if nfoPath != "" { logger.Debugf("trackers: attaching NFO file: %s", filepath.Base(nfoPath)) if err := addFile(writer, "nfo", nfoPath); err != nil { _ = writer.Close() - return nil, "", err + return "", "", err } } else { logger.Tracef("trackers: no NFO file found") } if err := writer.Close(); err != nil { - return nil, "", fmt.Errorf("trackers: UNIT3D close multipart writer: %w", err) + return "", "", fmt.Errorf("trackers: UNIT3D close multipart writer: %w", err) } - return strings.NewReader(builder.String()), writer.FormDataContentType(), nil + return builder.String(), writer.FormDataContentType(), nil } func addFile(writer *multipart.Writer, field, path string) error { @@ -510,11 +467,11 @@ func addFile(writer *multipart.Writer, field, path string) error { return nil } -func ensureUnit3DDVDVOBDescription(description string, meta api.PreparedMetadata) string { - return descriptionunit3d.AppendDVDVOBMediaInfoBlock(description, meta) +func ensureUnit3DDVDVOBDescription(description string, meta api.UploadSubject) string { + return descriptionunit3d.AppendDVDVOBMediaInfoBlock(description, api.NewDescriptionSubject(meta)) } -func loadUnit3DMedia(meta api.PreparedMetadata, dbPath string, logger api.Logger) (string, string, error) { +func loadUnit3DMedia(meta api.UploadSubject, dbPath string, logger api.Logger) (string, string, error) { bdinfo := "" mediainfo := "" @@ -560,7 +517,7 @@ func readTextFile(path string) (string, error) { return string(payload), nil } -func resolveTorrentPath(meta api.PreparedMetadata, dbPath string, logger api.Logger) (string, error) { +func resolveTorrentPath(meta api.UploadSubject, dbPath string, logger api.Logger) (string, error) { logger.Tracef("trackers: attempting to resolve torrent file path") candidates := []string{ strings.TrimSpace(meta.TorrentPath), @@ -600,15 +557,15 @@ func resolveTorrentPath(meta api.PreparedMetadata, dbPath string, logger api.Log return "", err } -func torrentPathFromTemp(tmpRoot string, meta api.PreparedMetadata) (string, error) { - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) +func torrentPathFromTemp(tmpRoot string, meta api.UploadSubject) (string, error) { + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "", fmt.Errorf("trackers: %w", err) } return filepath.Join(tmpDir, base+".torrent"), nil } -func resolveNFOPath(meta api.PreparedMetadata, dbPath string) string { +func resolveNFOPath(meta api.UploadSubject, dbPath string) string { if path := strings.TrimSpace(meta.SceneNFOPath); path != "" && existsFile(path) { return path } @@ -620,7 +577,7 @@ func resolveNFOPath(meta api.PreparedMetadata, dbPath string) string { if err != nil { return "" } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "" } @@ -640,31 +597,35 @@ func resolveNFOPath(meta api.PreparedMetadata, dbPath string) string { return "" } -func buildUnit3DData(req trackers.UploadRequest, name, description, mediainfo, bdinfo string) (map[string]string, error) { +func buildUnit3DData(req trackers.PreparationInput, name, description, mediainfo, bdinfo string, profiles ...SiteProfile) (map[string]string, error) { + profile := firstSiteProfile(profiles) meta := req.Meta - typeID, err := resolveUnit3DTypeIDForTracker(req.Tracker, meta) + typeID, err := resolveUnit3DTypeIDForTracker(req.Tracker, meta, profile) if err != nil { return nil, err } + if _, err := meta.Identity.RequireCategory(); err != nil { + return nil, fmt.Errorf("trackers: Unit3D category: %w", err) + } category := resolveUnit3DCategory(meta) data := map[string]string{ "name": name, "description": description, "mediainfo": mediainfo, "bdinfo": bdinfo, - "category_id": resolveUnit3DCategoryIDForTracker(req.Tracker, meta), + "category_id": resolveUnit3DCategoryIDForTracker(req.Tracker, meta, profile), "type_id": typeID, - "resolution_id": resolveUnit3DResolutionIDForTracker(req.Tracker, meta), - "tmdb": formatOptionalInt(meta.ExternalIDs.TMDBID), - "imdb": formatOptionalInt(meta.ExternalIDs.IMDBID), - "mal": formatOptionalInt(meta.ExternalIDs.MALID), + "resolution_id": resolveUnit3DResolutionIDForTracker(req.Tracker, meta, profile), + "tmdb": formatOptionalInt(meta.Identity.TMDBID), + "imdb": formatOptionalInt(meta.Identity.IMDBID), + "mal": formatOptionalInt(meta.Identity.MALID), "igdb": "0", "anonymous": boolFlag(req.TrackerConfig.Anon), "stream": boolFlag(meta.StreamOptimized != 0), "sd": boolFlag(isSDResolution(resolveResolution(meta))), - "keywords": resolveKeywordsForTracker(req.Tracker, meta), + "keywords": resolveKeywordsForTracker(req.Tracker, meta, profile), "personal_release": boolFlag(meta.PersonalRelease), - "internal": boolFlag(trackers.IsInternalGroup(req.AppConfig, req.Tracker, meta)), + "internal": boolFlag(req.Runtime.Internal), "featured": "0", "free": "0", "doubleup": "0", @@ -673,7 +634,7 @@ func buildUnit3DData(req trackers.UploadRequest, name, description, mediainfo, b if strings.EqualFold(category, "TV") { if !shouldIncludeUnit3DTVFields(meta, category) { - applyUnit3DAdditionalPayload(req, data) + applyUnit3DAdditionalPayload(req, data, profile) return data, nil } data["tvdb"] = formatOptionalInt(resolveTVDBID(meta)) @@ -681,26 +642,26 @@ func buildUnit3DData(req trackers.UploadRequest, name, description, mediainfo, b data["episode_number"] = resolveEpisode(meta) } - applyUnit3DAdditionalPayload(req, data) + applyUnit3DAdditionalPayload(req, data, profile) return data, nil } -func applyUnit3DAdditionalPayload(req trackers.UploadRequest, data map[string]string) { - profile, ok := unit3DSiteProfileFor(req.Tracker) - if !ok || profile.applyAdditionalPayload == nil { +func applyUnit3DAdditionalPayload(req trackers.PreparationInput, data map[string]string, profiles ...SiteProfile) { + profile := firstSiteProfile(profiles) + if profile.ApplyAdditionalPayload == nil { return } - profile.applyAdditionalPayload(req, data) + profile.ApplyAdditionalPayload(req, data) } -func resolveUnit3DTypeIDForTracker(tracker string, meta api.PreparedMetadata) (string, error) { +func resolveUnit3DTypeIDForTracker(tracker string, meta api.UploadSubject, profiles ...SiteProfile) (string, error) { trackerName := strings.ToUpper(strings.TrimSpace(tracker)) - profile, ok := unit3DSiteProfileFor(trackerName) - if !ok || profile.resolveTypeID == nil { + profile := firstSiteProfile(profiles) + if profile.ResolveTypeID == nil { return resolveUnit3DTypeID(meta) } - typeID := profile.resolveTypeID(meta) + typeID := profile.ResolveTypeID(meta) if strings.TrimSpace(typeID) == "" || typeID == "0" { resolvedType := inferUnit3DType(meta) if resolvedType == "" { @@ -711,97 +672,73 @@ func resolveUnit3DTypeIDForTracker(tracker string, meta api.PreparedMetadata) (s return typeID, nil } -func resolveUnit3DResolutionIDForTracker(tracker string, meta api.PreparedMetadata) string { - profile, ok := unit3DSiteProfileFor(tracker) - if ok && profile.resolveResolutionID != nil { - return profile.resolveResolutionID(meta) +func resolveUnit3DResolutionIDForTracker(_ string, meta api.UploadSubject, profiles ...SiteProfile) string { + profile := firstSiteProfile(profiles) + if profile.ResolveResolutionID != nil { + return profile.ResolveResolutionID(meta) } return resolveUnit3DResolutionID(meta) } -func resolveUnit3DCategoryIDForTracker(tracker string, meta api.PreparedMetadata) string { - profile, ok := unit3DSiteProfileFor(tracker) - if ok && profile.resolveCategoryID != nil { - return profile.resolveCategoryID(meta) +func resolveUnit3DCategoryIDForTracker(_ string, meta api.UploadSubject, profiles ...SiteProfile) string { + profile := firstSiteProfile(profiles) + if profile.ResolveCategoryID != nil { + return profile.ResolveCategoryID(meta) } return resolveUnit3DCategoryID(meta) } -func resolveKeywords(meta api.PreparedMetadata) string { - if meta.ExternalMetadata.TMDB != nil { - return strings.TrimSpace(meta.ExternalMetadata.TMDB.Keywords) +func resolveKeywords(meta api.UploadSubject) string { + if meta.ProviderMetadata.TMDB != nil { + return strings.TrimSpace(meta.ProviderMetadata.TMDB.Keywords) } return "" } -func resolveKeywordsForTracker(tracker string, meta api.PreparedMetadata) string { - if strings.EqualFold(strings.TrimSpace(tracker), "ACM") { - return resolveACMKeywords(meta) +func resolveKeywordsForTracker(_ string, meta api.UploadSubject, profiles ...SiteProfile) string { + profile := firstSiteProfile(profiles) + if profile.ResolveKeywords != nil { + return profile.ResolveKeywords(meta) } return resolveKeywords(meta) } -func resolveTVDBID(meta api.PreparedMetadata) int { +func resolveTVDBID(meta api.UploadSubject) int { if strings.EqualFold(resolveUnit3DCategory(meta), "TV") { - if meta.ExternalIDs.TVDBID != 0 { - return meta.ExternalIDs.TVDBID + if meta.Identity.TVDBID != 0 { + return meta.Identity.TVDBID } } return 0 } -// resolveUnit3DCategory resolves the upload category from recognized explicit -// tracker metadata, parsed release metadata, structured episode fields, then -// release-name heuristics. -func resolveUnit3DCategory(meta api.PreparedMetadata) string { - if category := resolveExplicitUnit3DCategory(meta.ExternalIDs.Category); category != "" { - return category - } - if category := resolveExplicitUnit3DCategory(meta.MediaInfoCategory); category != "" { - return category - } - if category := canonicalUnit3DCategory(meta.Release.Category); category != "" { - return category - } - if meta.HasTVSeasonEpisodeSignal() { - return "TV" - } - if hasSeasonEpisode(meta.ReleaseName) { - return "TV" - } - return "MOVIE" +// resolveUnit3DCategory maps only the canonical prepared identity category. +func resolveUnit3DCategory(meta api.UploadSubject) string { + return resolveExplicitUnit3DCategory(string(meta.Identity.Category)) } -// resolveExplicitUnit3DCategory treats unrecognized explicit values as absent -// so parsed release metadata can still determine the category. +// resolveExplicitUnit3DCategory accepts only finalized canonical values. func resolveExplicitUnit3DCategory(value string) string { - if strings.TrimSpace(value) == "" { + category, err := api.NormalizeCanonicalCategory(value) + if err != nil { return "" } - if category := canonicalUnit3DCategory(value); category != "" { - return category - } - return "" -} - -func canonicalUnit3DCategory(value string) string { - switch api.NormalizeCategory(value) { - case api.CategoryMovie: + switch category { + case api.CanonicalCategoryMovie: return "MOVIE" - case api.CategoryTV: + case api.CanonicalCategoryTV: return "TV" - case api.CategoryUnknown: - return "" - default: + case api.CanonicalCategoryUnknown: return "" } + return "" } -func resolveUnit3DCategoryID(meta api.PreparedMetadata) string { +func resolveUnit3DCategoryID(meta api.UploadSubject) string { return trackerdata.CategoryID(resolveUnit3DCategory(meta)) } -func resolveUnit3DTypeID(meta api.PreparedMetadata) (string, error) { +func resolveUnit3DTypeID(meta api.UploadSubject) (string, error) { typeValue := inferUnit3DType(meta) if value := trackerdata.TypeID(typeValue); value != "" { return value, nil @@ -815,7 +752,7 @@ func resolveUnit3DTypeID(meta api.PreparedMetadata) (string, error) { return "", fmt.Errorf("trackers: unit3d unsupported type value %q", typeValue) } -func inferUnit3DType(meta api.PreparedMetadata) string { +func inferUnit3DType(meta api.UploadSubject) string { for _, candidate := range []string{meta.Type, meta.Release.Type} { normalized := normalizeUnit3DTypeCandidate(candidate) if normalized != "" && !isUnit3DCategoryType(normalized) { @@ -909,41 +846,11 @@ func isUnit3DCategoryType(value string) bool { } } -func shouldIncludeUnit3DTVFields(meta api.PreparedMetadata, category string) bool { - if !strings.EqualFold(strings.TrimSpace(category), "TV") { - return false - } - - if hasExplicitMovieSignal(meta) { - return false - } - - if strings.EqualFold(strings.TrimSpace(meta.MediaInfoCategory), "TV") { - return true - } - if meta.HasTVSeasonEpisodeSignal() { - return true - } - - releaseType := normalizeUnit3DTypeCandidate(meta.Release.Type) - if strings.EqualFold(releaseType, "EPISODE") || strings.EqualFold(releaseType, "SERIES") { - return true - } - - return hasSeasonEpisode(meta.ReleaseName) +func shouldIncludeUnit3DTVFields(_ api.UploadSubject, category string) bool { + return strings.EqualFold(strings.TrimSpace(category), "TV") } -func hasExplicitMovieSignal(meta api.PreparedMetadata) bool { - for _, value := range []string{meta.ExternalIDs.Category, meta.MediaInfoCategory, meta.Type, meta.Release.Type} { - normalized := normalizeUnit3DTypeCandidate(value) - if strings.EqualFold(normalized, "MOVIE") || strings.EqualFold(normalized, "FILM") { - return true - } - } - return false -} - -func resolveUnit3DResolutionID(meta api.PreparedMetadata) string { +func resolveUnit3DResolutionID(meta api.UploadSubject) string { resolution := resolveResolution(meta) if value := trackerdata.ResolutionID(resolution); value != "" { return value @@ -951,10 +858,14 @@ func resolveUnit3DResolutionID(meta api.PreparedMetadata) string { return "10" } -func resolveResolution(meta api.PreparedMetadata) string { - resolution := strings.TrimSpace(meta.Release.Resolution) +func resolveResolution(meta api.UploadSubject) string { + return resolveResolutionValues(meta.Release, meta.ReleaseName) +} + +func resolveResolutionValues(release api.ReleaseInfo, releaseName string) string { + resolution := strings.TrimSpace(release.Resolution) if resolution == "" { - resolution = detectResolution(meta.ReleaseName) + resolution = detectResolution(releaseName) } return resolution } @@ -970,7 +881,7 @@ func detectResolution(value string) string { } // resolveSeason returns the Unit3D payload season value from SeasonInt only. -func resolveSeason(meta api.PreparedMetadata) string { +func resolveSeason(meta api.UploadSubject) string { if meta.SeasonInt <= 0 { return "0" } @@ -978,7 +889,7 @@ func resolveSeason(meta api.PreparedMetadata) string { } // resolveEpisode returns the Unit3D payload episode value from EpisodeInt only. -func resolveEpisode(meta api.PreparedMetadata) string { +func resolveEpisode(meta api.UploadSubject) string { if meta.EpisodeInt <= 0 { return "0" } @@ -988,7 +899,7 @@ func resolveEpisode(meta api.PreparedMetadata) string { // validateUnit3DTVPayloadMetadata returns the shared Unit3D TV metadata block // reason used by live upload and dry-run when canonical season or episode data // is missing from payload fields that would otherwise be submitted as zero. -func validateUnit3DTVPayloadMetadata(trackerName string, meta api.PreparedMetadata, data map[string]string) (string, error) { +func validateUnit3DTVPayloadMetadata(trackerName string, meta api.UploadSubject, data map[string]string) (string, error) { message := unit3DTVPayloadMetadataMessage(meta, data) if message == "" { return "", nil @@ -1000,7 +911,7 @@ func validateUnit3DTVPayloadMetadata(trackerName string, meta api.PreparedMetada // canonical season or episode metadata is missing. Parsed release and manual // naming values are reported only as ignored signals, and the message includes // the operator action required by blocked dry-run entries. -func unit3DTVPayloadMetadataMessage(meta api.PreparedMetadata, data map[string]string) string { +func unit3DTVPayloadMetadataMessage(meta api.UploadSubject, data map[string]string) string { if _, hasSeason := data["season_number"]; !hasSeason { return "" } @@ -1033,7 +944,7 @@ func unit3DTVPayloadMetadataMessage(meta api.PreparedMetadata, data map[string]s return message } -func hasParsedSeasonSignal(meta api.PreparedMetadata) bool { +func hasParsedSeasonSignal(meta api.UploadSubject) bool { if meta.Release.Season > 0 { return true } @@ -1044,7 +955,7 @@ func hasParsedSeasonSignal(meta api.PreparedMetadata) bool { return season > 0 } -func hasParsedEpisodeSignal(meta api.PreparedMetadata) bool { +func hasParsedEpisodeSignal(meta api.UploadSubject) bool { if meta.Release.Episode > 0 { return true } @@ -1070,10 +981,6 @@ func parseSeasonEpisode(name string) (int, int) { return season, episode } -func hasSeasonEpisode(name string) bool { - return seasonEpisodePattern.MatchString(name) -} - func isSDResolution(resolution string) bool { switch strings.ToLower(strings.TrimSpace(resolution)) { case "480p", "480i", "576p", "576i": diff --git a/internal/trackers/impl/unit3d/upload_doc.go b/internal/trackers/impl/unit3d/upload_doc.go index 4cc4c71d..174a43bb 100644 --- a/internal/trackers/impl/unit3d/upload_doc.go +++ b/internal/trackers/impl/unit3d/upload_doc.go @@ -3,5 +3,35 @@ package unit3d -// Unit3D tracker uploads live here, with per-tracker overrides handled in the -// individual files (Aither is the first). +// Unit3D upload contract +// +// The upstream UNIT3D Torrent API documents uploads as multipart POST requests +// to /api/torrents/upload. API authentication may be supplied as an api_token +// query/form value or as a Bearer token. upbrr uses Bearer authentication and +// never includes credentials in diagnostic payload output; see +// authentication_doc.go for the local security contract. +// +// Upstream request fields are grouped as follows: +// +// - Files: torrent and optional nfo. +// - Description data: name, description, mediainfo, and bdinfo. +// - Classification IDs: category_id, type_id, resolution_id, region_id, and +// distributor_id. +// - TV coordinates: season_number and episode_number. +// - External IDs: tmdb, imdb, tvdb, mal, and the games-only igdb field. +// - Release flags: anonymous, personal_release, and internal. +// - Staff/internal controls: refundable, featured, free, fl_until, doubleup, +// du_until, sticky, and mod_queue_opt_in. +// +// Boolean values are encoded through boolFlag. Category, type, resolution, and +// external identifiers are resolved by the common Unit3D engine before site +// profiles apply their overrides. TV-only season and episode fields are omitted +// for movie uploads. The torrent and NFO fields are attached as multipart files; +// all remaining values are form fields. +// +// Individual UNIT3D deployments may extend the upstream contract. Fields such +// as internalrip, stream, sd, pack, keywords, modq, and site-specific IDs are +// compatibility extensions selected by site profiles or tracker configuration; +// they are not part of the upstream baseline documented above. +// +// Source: https://github.com/HDInnovations/UNIT3D/blob/master/book/src/torrent_api.md diff --git a/internal/trackers/impl/unit3d/upload_test.go b/internal/trackers/impl/unit3d/upload_test.go index 6f924ce9..232c2294 100644 --- a/internal/trackers/impl/unit3d/upload_test.go +++ b/internal/trackers/impl/unit3d/upload_test.go @@ -16,7 +16,6 @@ import ( "testing" "github.com/autobrr/upbrr/internal/config" - descriptionunit3d "github.com/autobrr/upbrr/internal/services/description/unit3d" "github.com/autobrr/upbrr/internal/trackers" "github.com/autobrr/upbrr/pkg/api" ) @@ -52,110 +51,137 @@ func (l *captureUnit3DLogger) containsWarning(value string) bool { return false } +func TestDefinitionsRetainIndependentSiteProfiles(t *testing.T) { + first := NewWithProfile(Profile{ + Name: "FIRST", + BannedGroups: []string{"GROUP-A"}, + Site: SiteProfile{ + ResolveTypeID: func(api.UploadSubject) string { return "1" }, + }, + }) + second := NewWithProfile(Profile{ + Name: "SECOND", + Site: SiteProfile{ + ResolveTypeID: func(api.UploadSubject) string { return "2" }, + }, + }) + + if got := first.profile.Site.ResolveTypeID(api.UploadSubject{}); got != "1" { + t.Fatalf("first profile type ID = %q, want 1", got) + } + if got := second.profile.Site.ResolveTypeID(api.UploadSubject{}); got != "2" { + t.Fatalf("second profile type ID = %q, want 2", got) + } + groups := first.BannedGroups() + groups[0] = "CHANGED" + if got := first.BannedGroups()[0]; got != "GROUP-A" { + t.Fatalf("definition exposed mutable banned groups: %q", got) + } +} + func TestResolveUnit3DCategory(t *testing.T) { tests := []struct { name string - meta api.PreparedMetadata + meta api.UploadSubject want string }{ { name: "external movie", - meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "movie"}}, + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: "movie"}}, want: "MOVIE", }, { name: "external tv", - meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}}, + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: "TV"}}, want: "TV", }, { - name: "external tv alias", - meta: api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: " tv-show "}}, - want: "TV", + name: "noncanonical tv alias", + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: " tv-show "}}, + want: "", }, { - name: "external movie alias", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: " film "}, - Release: api.ReleaseInfo{Category: "TV"}, + name: "canonical movie", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "movie"}, + Release: api.ReleaseInfo{Category: "TV"}, }, want: "MOVIE", }, { name: "external wins over release", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Release: api.ReleaseInfo{Category: "TV"}, + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + Release: api.ReleaseInfo{Category: "TV"}, }, want: "MOVIE", }, { - name: "mediainfo wins over release", - meta: api.PreparedMetadata{ - MediaInfoCategory: "movie", - Release: api.ReleaseInfo{Category: "episode"}, + name: "canonical identity wins over release", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "movie"}, + Release: api.ReleaseInfo{Category: "episode"}, }, want: "MOVIE", }, { - name: "unknown external uses mediainfo", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "documentary"}, - MediaInfoCategory: "TV", + name: "canonical tv identity", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, }, want: "TV", }, { - name: "unknown external uses release", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "documentary"}, - Release: api.ReleaseInfo{Category: "series"}, + name: "unknown external ignores release", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "documentary"}, + Release: api.ReleaseInfo{Category: "series"}, }, - want: "TV", + want: "", }, { - name: "release category tv alias", - meta: api.PreparedMetadata{Release: api.ReleaseInfo{Category: " series "}}, - want: "TV", + name: "release category is not canonical identity", + meta: api.UploadSubject{Release: api.ReleaseInfo{Category: " series "}}, + want: "", }, { - name: "release category movie alias", - meta: api.PreparedMetadata{ + name: "release movie category is not canonical identity", + meta: api.UploadSubject{ ReleaseName: "Example.Movie.2026.1080p.WEB-DL-GRP", Release: api.ReleaseInfo{Category: "film"}, }, - want: "MOVIE", + want: "", }, { - name: "structured episode fields", - meta: api.PreparedMetadata{ + name: "structured episode fields are not identity", + meta: api.UploadSubject{ ReleaseName: "Show.1x01.1080p.WEB-DL-GRP", SeasonInt: 1, EpisodeInt: 1, }, - want: "TV", + want: "", }, { - name: "unknown mediainfo uses release name fallback", - meta: api.PreparedMetadata{ - MediaInfoCategory: "documentary", - ReleaseName: "Show.S01E01.1080p.WEB-DL-GRP", + name: "canonical tv identity ignores release name inference", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + ReleaseName: "Show.S01E01.1080p.WEB-DL-GRP", }, want: "TV", }, { - name: "whitespace external uses structured episode fields", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: " \t "}, - SeasonInt: 1, - EpisodeInt: 1, + name: "whitespace identity remains unknown", + meta: api.UploadSubject{ + Identity: api.ExternalIdentity{Category: " \t "}, + SeasonInt: 1, + EpisodeInt: 1, }, - want: "TV", + want: "", }, { - name: "release name fallback", - meta: api.PreparedMetadata{ReleaseName: "Show.S01E01.1080p.WEB-DL-GRP"}, - want: "TV", + name: "release name is not canonical identity", + meta: api.UploadSubject{ReleaseName: "Show.S01E01.1080p.WEB-DL-GRP"}, + want: "", }, } @@ -167,13 +193,13 @@ func TestResolveUnit3DCategory(t *testing.T) { } } -func TestBuildUnit3DDataUsesParsedCategoryWhenExplicitCategoryUnsupported(t *testing.T) { - tvReq := trackers.UploadRequest{ +func TestBuildUnit3DDataUsesCanonicalCategory(t *testing.T) { + tvReq := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Show.S02E03.Episode.Title.1080p.WEB-DL-GRP", - ExternalIDs: api.ExternalIDs{ - Category: "documentary", + Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryTV, TMDBID: 123, IMDBID: 456, TVDBID: 789, @@ -207,12 +233,12 @@ func TestBuildUnit3DDataUsesParsedCategoryWhenExplicitCategoryUnsupported(t *tes t.Fatalf("expected tvdb=789, got %q", got) } - movieReq := trackers.UploadRequest{ + movieReq := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Movie.2025.1080p.WEB-DL-GRP", - ExternalIDs: api.ExternalIDs{ - Category: "documentary", + Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryMovie, TVDBID: 789, }, Type: "WEBDL", @@ -241,30 +267,12 @@ func TestBuildUnit3DDataUsesParsedCategoryWhenExplicitCategoryUnsupported(t *tes } } -func TestBuildAitherNameDVDRip(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie.2020.DVD.DVDRIP.AAC.XVID-GRP", - Release: api.ReleaseInfo{Year: 2020, Resolution: "480p"}, - Type: "DVDRIP", - Source: "DVD", - Audio: "AAC 2.0", - VideoEncode: "XVID", - } - name := BuildAitherName(meta) - if name == "" { - t.Fatalf("expected aither name") - } - if name == meta.ReleaseName { - t.Fatalf("expected name to be adjusted, got %q", name) - } -} - func TestBuildUnit3DDataMovieOmitsTVOnlyFields(t *testing.T) { - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "MOVIE", TMDBID: 123, IMDBID: 456, @@ -293,67 +301,12 @@ func TestBuildUnit3DDataMovieOmitsTVOnlyFields(t *testing.T) { } } -func TestBuildUnit3DDataAitherHDR10PExcludesHDR(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "AITHER", - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.2160p.WEB-DL.HDR10+.DV.H265-GRP", - HDR: "HDR10+ DV", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - Release: api.ReleaseInfo{ - Resolution: "2160p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := data["hdr10p"]; got != "1" { - t.Fatalf("expected hdr10p=1, got %q", got) - } - if _, ok := data["hdr"]; ok { - t.Fatalf("did not expect hdr when hdr10p is set") - } - if got := data["dv"]; got != "1" { - t.Fatalf("expected dv=1, got %q", got) - } -} - -func TestBuildUnit3DDataAitherHDRWithoutHDR10P(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "AITHER", - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.2160p.WEB-DL.HDR.H265-GRP", - HDR: "HDR", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - Release: api.ReleaseInfo{ - Resolution: "2160p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := data["hdr"]; got != "1" { - t.Fatalf("expected hdr=1, got %q", got) - } - if _, ok := data["hdr10p"]; ok { - t.Fatalf("did not expect hdr10p for plain HDR") - } -} - func TestBuildUnit3DDataTVIncludesTVOnlyFields(t *testing.T) { - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Show.S02E03.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", TMDBID: 123, IMDBID: 456, @@ -385,11 +338,11 @@ func TestBuildUnit3DDataTVIncludesTVOnlyFields(t *testing.T) { } func TestBuildUnit3DDataTVOmitsParsedReleaseSeasonEpisodeFallback(t *testing.T) { - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Daily.Show.2025.07.01.1080p.WEB-DL-GRP", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "TV", TVDBID: 789, }, @@ -433,17 +386,17 @@ func TestBuildUnit3DDryRunBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { t.Fatalf("write torrent: %v", err) } - entry, err := buildUploadDryRunUnit3D(context.Background(), trackers.UploadRequest{ + entry, err := buildUploadDryRunUnit3D(context.Background(), trackers.PreparationInput{ Tracker: "AITHER", TrackerConfig: config.TrackerConfig{ APIKey: "test-key", }, - Meta: api.PreparedMetadata{ - ReleaseName: "Daily.Show.2025.07.01.1080p.WEB-DL-GRP", - TorrentPath: torrentPath, - MediaInfoTextPath: mediaInfoPath, - ValidMediaInfoSettings: true, - ExternalIDs: api.ExternalIDs{ + Meta: api.UploadSubject{ + ReleaseName: "Daily.Show.2025.07.01.1080p.WEB-DL-GRP", + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Assessments: api.ReleaseAssessments{MediaInfoEncodeSettings: api.EncodeSettingsStatusPresent}, + Identity: api.ExternalIdentity{ Category: "TV", TVDBID: 789, }, @@ -459,7 +412,7 @@ func TestBuildUnit3DDryRunBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { Description: "description", Final: true, }, - }) + }, "https://aither.cc") if err != nil { t.Fatalf("build Unit3D dry-run: %v", err) } @@ -490,19 +443,17 @@ func TestUploadUnit3DBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { t.Cleanup(server.Close) logger := &captureUnit3DLogger{} - _, err := uploadUnit3D(context.Background(), trackers.UploadRequest{ - Tracker: "AITHER", - TrackerConfig: config.TrackerConfig{ - URL: server.URL, - APIKey: "test-key", - }, - Logger: logger, - Meta: api.PreparedMetadata{ - ReleaseName: "Daily.Show.2025.07.01.1080p.WEB-DL-GRP", - TorrentPath: torrentPath, - MediaInfoTextPath: mediaInfoPath, - ValidMediaInfoSettings: true, - ExternalIDs: api.ExternalIDs{ + _, err := prepareUnit3DUpload(context.Background(), trackers.PreparationInput{ + Intent: trackers.PreparationIntentUpload, + Tracker: "AITHER", + TrackerConfig: config.TrackerConfig{APIKey: "test-key"}, + Logger: logger, + Meta: api.UploadSubject{ + ReleaseName: "Example.Show.2026.S01E01.1080p.WEB-DL-GRP", + TorrentPath: torrentPath, + MediaInfoTextPath: mediaInfoPath, + Assessments: api.ReleaseAssessments{MediaInfoEncodeSettings: api.EncodeSettingsStatusPresent}, + Identity: api.ExternalIdentity{ Category: "TV", TVDBID: 789, }, @@ -518,7 +469,7 @@ func TestUploadUnit3DBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { Description: "description", Final: true, }, - }) + }, server.URL) if err == nil { t.Fatal("expected canonical TV metadata gap to block upload") } @@ -535,11 +486,11 @@ func TestUploadUnit3DBlocksMissingCanonicalTVSeasonEpisode(t *testing.T) { } func TestBuildUnit3DDataFailsOnUnknownType(t *testing.T) { - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Movie.2025.1080p.UNKNOWN-GRP", - ExternalIDs: api.ExternalIDs{ + Identity: api.ExternalIdentity{ Category: "MOVIE", }, Type: "", @@ -560,7 +511,7 @@ func TestBuildUnit3DDataFailsOnUnknownType(t *testing.T) { } func TestResolveUnit3DTypeIDInfersWEBDLFromSourceWhenReleaseTypeIsMovie(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ Type: "movie", Source: "WEB-DL", Release: api.ReleaseInfo{ @@ -579,7 +530,7 @@ func TestResolveUnit3DTypeIDInfersWEBDLFromSourceWhenReleaseTypeIsMovie(t *testi } func TestResolveUnit3DTypeIDInfersEncodeFromBluraySourceWhenReleaseTypeIsMovie(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ Type: "movie", Source: "BluRay", Release: api.ReleaseInfo{ @@ -598,7 +549,7 @@ func TestResolveUnit3DTypeIDInfersEncodeFromBluraySourceWhenReleaseTypeIsMovie(t } func TestResolveUnit3DIDsUseSharedTrackerdataMappings(t *testing.T) { - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ Type: "WEB-DL", Release: api.ReleaseInfo{ Resolution: "1080P", @@ -619,18 +570,17 @@ func TestResolveUnit3DIDsUseSharedTrackerdataMappings(t *testing.T) { } func TestBuildUnit3DDataSkipsTVFieldsWhenMovieSignalsExist(t *testing.T) { - req := trackers.UploadRequest{ + req := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Example.Movie.2026.2160p.WEB-DL.DDP5.1.H.265-GRP", - ExternalIDs: api.ExternalIDs{ - Category: "TV", + Identity: api.ExternalIdentity{ + Category: "movie", TMDBID: 765432, IMDBID: 1234567, }, - MediaInfoCategory: "movie", - Type: "movie", - Source: "WEB-DL", + Type: "movie", + Source: "WEB-DL", Release: api.ReleaseInfo{ Type: "movie", Source: "WEB-DL", @@ -658,15 +608,18 @@ func TestBuildUnit3DDataSkipsTVFieldsWhenMovieSignalsExist(t *testing.T) { } } -func TestBuildUnit3DDataUsesParsedCategoryWhenExplicitCategoriesBlank(t *testing.T) { - tvReq := trackers.UploadRequest{ +func TestBuildUnit3DDataUsesCanonicalCategoryWithParsedReleaseFacts(t *testing.T) { + tvReq := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Show.1x01.Episode.Title.1080p.WEB-DL-GRP", - ExternalIDs: api.ExternalIDs{TVDBID: 789}, - Type: "WEBDL", - SeasonInt: 1, - EpisodeInt: 1, + Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryTV, + TVDBID: 789, + }, + Type: "WEBDL", + SeasonInt: 1, + EpisodeInt: 1, Release: api.ReleaseInfo{ Category: "TV", Season: 1, @@ -693,12 +646,15 @@ func TestBuildUnit3DDataUsesParsedCategoryWhenExplicitCategoriesBlank(t *testing t.Fatalf("expected tvdb=789, got %q", got) } - movieReq := trackers.UploadRequest{ + movieReq := trackers.PreparationInput{ Tracker: "AITHER", - Meta: api.PreparedMetadata{ + Meta: api.UploadSubject{ ReleaseName: "Example.Movie.2026.1080p.WEB-DL-GRP", - ExternalIDs: api.ExternalIDs{TVDBID: 789}, - Type: "WEBDL", + Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryMovie, + TVDBID: 789, + }, + Type: "WEBDL", Release: api.ReleaseInfo{ Category: "MOVIE", Year: 2026, @@ -755,496 +711,18 @@ func TestParseUnit3DUploadArtifactNumericID(t *testing.T) { } } -func TestResolveUnit3DTypeIDForTrackerOE(t *testing.T) { - meta := api.PreparedMetadata{Type: "WEBRIP", VideoCodec: "HEVC"} - got, err := resolveUnit3DTypeIDForTracker("OE", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "10" { - t.Fatalf("expected OE WEBRIP HEVC type_id=10, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerOEFallsBackToOtherUnknown(t *testing.T) { - meta := api.PreparedMetadata{Type: "WEBRIP", VideoCodec: "VP9"} - got, err := resolveUnit3DTypeIDForTracker("OE", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "16" { - t.Fatalf("expected OE WEBRIP unknown codec type_id=16, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerOTWDVD(t *testing.T) { - meta := api.PreparedMetadata{DiscType: "DVD", Type: "REMUX"} - got, err := resolveUnit3DTypeIDForTracker("OTW", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "7" { - t.Fatalf("expected OTW DVD type_id=7, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerRF(t *testing.T) { - meta := api.PreparedMetadata{Type: "ENCODE"} - got, err := resolveUnit3DTypeIDForTracker("RF", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "41" { - t.Fatalf("expected RF ENCODE type_id=41, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerYUS(t *testing.T) { - meta := api.PreparedMetadata{Type: "DISC"} - got, err := resolveUnit3DTypeIDForTracker("YUS", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "17" { - t.Fatalf("expected YUS DISC type_id=17, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerZNTH(t *testing.T) { - meta := api.PreparedMetadata{Type: "DVDRIP"} - got, err := resolveUnit3DTypeIDForTracker("ZNTH", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "11" { - t.Fatalf("expected ZNTH DVDRIP type_id=11, got %q", got) - } -} - -func TestBuildZNTHNameTV(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Show.S01E01.Episode.Title.1080p.WEB-DL-GRP", - EpisodeTitle: "Episode Title", - ExternalIDs: api.ExternalIDs{Category: "TV"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Show.S01E01.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameMovieYearMismatch(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie.2024.1080p.WEB-DL-GRP", - Release: api.ReleaseInfo{Year: 2024}, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - }, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Movie.2025.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameBlankCategoryUsesParsedTVCategory(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Show.1x01.Episode.Title.1080p.WEB-DL-GRP", - EpisodeTitle: "Episode Title", - SeasonInt: 1, - EpisodeInt: 1, - Release: api.ReleaseInfo{ - Category: "TV", - Resolution: "1080p", - }, - } - - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Show.1x01.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameUnknownExplicitCategoryUsesParsedTVCategory(t *testing.T) { - tests := []struct { - name string - meta api.PreparedMetadata - }{ - { - name: "external unknown", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "animation"}, - }, - }, - { - name: "mediainfo unknown", - meta: api.PreparedMetadata{ - MediaInfoCategory: "animation", - }, - }, - { - name: "external unknown falls through to mediainfo tv", - meta: api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "animation"}, - MediaInfoCategory: "TV", - }, - }, - } - - for _, tc := range tests { - tc.meta.ReleaseName = "Show.S01E01.2024.Episode.Title.1080p.WEB-DL-GRP" - tc.meta.EpisodeTitle = "Episode Title" - tc.meta.SeasonInt = 1 - tc.meta.EpisodeInt = 1 - tc.meta.Release = api.ReleaseInfo{ - Category: "TV", - Resolution: "1080p", - Year: 2024, - } - tc.meta.ExternalMetadata = api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - } - - got := buildUnit3DName("ZNTH", tc.meta, config.TrackerConfig{}) - expected := "Show.S01E01.2024.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("%s: expected %q, got %q", tc.name, expected, got) - } - } -} - -func TestBuildZNTHNameExplicitMoviePreservesMovieBranchOverParsedTV(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Show.S01E01.2024.Episode.Title.1080p.WEB-DL-GRP", - EpisodeTitle: "Episode Title", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - SeasonInt: 1, - EpisodeInt: 1, - Release: api.ReleaseInfo{ - Category: "TV", - Resolution: "1080p", - Year: 2024, - }, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - }, - } - - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Show.S01E01.2025.Episode.Title.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameBlankCategoryUsesParsedMovieCategory(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Example.Movie.2026.1080p.WEB-DL-GRP", - Release: api.ReleaseInfo{ - Category: "MOVIE", - Year: 2026, - }, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2027}, - }, - } - - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Example.Movie.2027.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameMovieYearMismatchNoResolutionHyphenatedTitle(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie - Part One 2024", - Release: api.ReleaseInfo{Year: 2024}, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - }, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Movie - Part One 2025" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameMovieYearMismatchNoResolutionGroupSuffix(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie.Title.2024-GRP2024", - Release: api.ReleaseInfo{Year: 2024, Group: "GRP2024"}, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - }, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "Movie.Title.2025-GRP2024" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameTVUnicodePrefix(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "\u212aShow.S01E01.Episode.Title.1080p.WEB-DL-GRP", - EpisodeTitle: "Episode Title", - ExternalIDs: api.ExternalIDs{Category: "TV"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "\u212aShow.S01E01.1080p.WEB-DL-GRP" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestBuildZNTHNameMovieYearMismatchUnicodeTitle(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "\u212aMovie.2024", - Release: api.ReleaseInfo{Year: 2024}, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - IMDB: &api.IMDBMetadata{Year: 2025}, - }, - } - got := buildUnit3DName("ZNTH", meta, config.TrackerConfig{}) - expected := "\u212aMovie.2025" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestFindZNTHTokenIndexesUnicodeBoundaries(t *testing.T) { - got := findZNTHTokenIndexes("Title.\u212a.1080p.Source", "1080p") - expected := len("Title.\u212a.") - if len(got) != 1 || got[0] != expected { - t.Fatalf("expected index %d, got %#v", expected, got) - } - - if got := findZNTHTokenIndexes("Title.\u06611080p.Source", "1080p"); len(got) != 0 { - t.Fatalf("expected adjacent Unicode digit prefix to reject token, got %#v", got) - } - if got := findZNTHTokenIndexes("Title.1080p\u0661.Source", "1080p"); len(got) != 0 { - t.Fatalf("expected adjacent Unicode digit suffix to reject token, got %#v", got) - } -} - -func TestZNTHEmptyTokenInputs(t *testing.T) { - name := "Show.S01E01.1080p.WEB-DL-GRP" - if got := replaceZNTHEpisodeTitle(name, "", "1080p"); got != name { - t.Fatalf("expected empty episode title to leave name unchanged, got %q", got) - } - if got := findZNTHTokenIndexes(name, " "); got != nil { - t.Fatalf("expected empty token indexes to be nil, got %#v", got) - } -} - -func TestResolveUnit3DResolutionIDForTrackerRF(t *testing.T) { - meta := api.PreparedMetadata{Release: api.ReleaseInfo{Resolution: "1440p"}} - if got := resolveUnit3DResolutionIDForTracker("RF", meta); got != "10" { - t.Fatalf("expected RF 1440p to fall back to 10, got %q", got) - } -} - -func TestBuildUnit3DDataLSTAdditionalFields(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "LST", +func TestBuildUnit3DDataOmitsLegacyModQAliasForA4K(t *testing.T) { + req := trackers.PreparationInput{ + Tracker: "A4K", TrackerConfig: config.TrackerConfig{ - Draft: true, + ModQ: true, }, - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, + Meta: api.UploadSubject{ + ReleaseName: "Movie.2025.2160p.WEB-DL.DD5.1.H264-GRP", + Identity: api.ExternalIdentity{Category: "MOVIE"}, Type: "WEBDL", - Edition: "Director's Cut", - Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if got := data["draft_queue_opt_in"]; got != "1" { - t.Fatalf("expected draft_queue_opt_in=1, got %q", got) - } - if got := data["edition_id"]; got != "2" { - t.Fatalf("expected edition_id=2, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerA4K(t *testing.T) { - meta := api.PreparedMetadata{Type: "WEBRIP"} - _, err := resolveUnit3DTypeIDForTracker("A4K", meta) - if err == nil { - t.Fatalf("expected unsupported type for A4K WEBRIP") - } -} - -func TestResolveUnit3DTypeIDForTrackerPT(t *testing.T) { - meta := api.PreparedMetadata{Type: "WEBRIP"} - got, err := resolveUnit3DTypeIDForTracker("PT", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "39" { - t.Fatalf("expected PT WEBRIP type_id=39, got %q", got) - } -} - -func TestBuildUnit3DDataPTAdditionalFields(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "PT", - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - AudioLanguages: []string{"Portuguese"}, - SubtitleLanguages: []string{"PT-BR", "English"}, Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if got := data["audio_pt"]; got != "1" { - t.Fatalf("expected audio_pt=1, got %q", got) - } - if got := data["legenda_pt"]; got != "0" { - t.Fatalf("expected legenda_pt=0 when only BR Portuguese subtitles are present, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerSTCPack(t *testing.T) { - meta := api.PreparedMetadata{ - Type: "WEBDL", - TVPack: true, - Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - } - got, err := resolveUnit3DTypeIDForTracker("STC", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "13" { - t.Fatalf("expected STC HD web pack type_id=13, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerTLZ(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}, TVPack: true} - got, err := resolveUnit3DTypeIDForTracker("TLZ", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "4" { - t.Fatalf("expected TLZ TV pack type_id=4, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTOS(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}, TVPack: true, Tag: "-vostfr"} - if got := resolveUnit3DCategoryIDForTracker("TOS", meta); got != "9" { - t.Fatalf("expected TOS vostfr TV pack category_id=9, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerBLU(t *testing.T) { - meta := api.PreparedMetadata{Type: "ENCODE"} - got, err := resolveUnit3DTypeIDForTracker("BLU", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "12" { - t.Fatalf("expected BLU ENCODE type_id=12, got %q", got) - } -} - -func TestResolveUnit3DResolutionIDForTrackerBLU(t *testing.T) { - meta := api.PreparedMetadata{Release: api.ReleaseInfo{Resolution: "2160p"}} - if got := resolveUnit3DResolutionIDForTracker("BLU", meta); got != "1" { - t.Fatalf("expected BLU 2160p resolution_id=1, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerITTFromName(t *testing.T) { - meta := api.PreparedMetadata{ReleaseName: "Movie.2025.1080p.DLMux-GRP", Type: "WEBDL"} - got, err := resolveUnit3DTypeIDForTracker("ITT", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "27" { - t.Fatalf("expected ITT DLMux type_id=27, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerSHRI(t *testing.T) { - meta := api.PreparedMetadata{Type: "REMUX"} - got, err := resolveUnit3DTypeIDForTracker("SHRI", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "7" { - t.Fatalf("expected SHRI REMUX type_id=7, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerIHDAnimeMovie(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, Anime: true} - if got := resolveUnit3DCategoryIDForTracker("IHD", meta); got != "4" { - t.Fatalf("expected IHD anime movie category_id=4, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerR4EDocumentaryTV(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "TV"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{GenreIDs: "99,18"}, - }, - } - if got := resolveUnit3DCategoryIDForTracker("R4E", meta); got != "2" { - t.Fatalf("expected R4E documentary tv category_id=2, got %q", got) - } -} - -func TestResolveUnit3DResolutionIDForTrackerUTPDefaultOther(t *testing.T) { - meta := api.PreparedMetadata{Release: api.ReleaseInfo{Resolution: "720p"}} - if got := resolveUnit3DResolutionIDForTracker("UTP", meta); got != "11" { - t.Fatalf("expected UTP 720p resolution fallback=11, got %q", got) - } -} - -func TestBuildUnit3DDataOmitsLegacyModQAliasForA4K(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "A4K", - TrackerConfig: config.TrackerConfig{ - ModQ: true, - }, - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.2160p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - Release: api.ReleaseInfo{ - Resolution: "2160p", + Resolution: "2160p", }, }, } @@ -1257,633 +735,3 @@ func TestBuildUnit3DDataOmitsLegacyModQAliasForA4K(t *testing.T) { t.Fatalf("did not expect legacy modq alias for A4K") } } - -func TestResolveUnit3DTypeIDForTrackerTIKDiscMarker(t *testing.T) { - meta := api.PreparedMetadata{ReleaseName: "Movie.2025.BD50.COMPLETE-GRP", DiscType: "BDMV"} - got, err := resolveUnit3DTypeIDForTracker("TIK", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "5" { - t.Fatalf("expected TIK BD50 type_id=5, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerTIKNonDiscFails(t *testing.T) { - meta := api.PreparedMetadata{Type: "WEBDL", DiscType: ""} - _, err := resolveUnit3DTypeIDForTracker("TIK", meta) - if err == nil { - t.Fatalf("expected error for TIK when disc type cannot be resolved") - } -} - -func TestResolveUnit3DCategoryIDForTrackerLDUAnimeTV(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "TV"}, Anime: true} - if got := resolveUnit3DCategoryIDForTracker("LDU", meta); got != "9" { - t.Fatalf("expected LDU anime tv category_id=9, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerLDUNonEnglishMovie(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - AudioLanguages: []string{"Portuguese"}, - SubtitleLanguages: []string{"Portuguese"}, - } - if got := resolveUnit3DCategoryIDForTracker("LDU", meta); got != "22" { - t.Fatalf("expected LDU non-english movie category_id=22, got %q", got) - } -} - -func TestBuildUnit3DNameLDUUsesFirstParseableLanguages(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - AudioLanguages: []string{"", "Japanese", "English"}, - SubtitleLanguages: []string{"", "English"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, - }, - } - - got := buildUnit3DName("LDU", meta, config.TrackerConfig{}) - if !strings.Contains(got, "[JPN]") { - t.Fatalf("expected first parseable audio language suffix, got %q", got) - } - if !strings.Contains(got, "[Subs ENG]") { - t.Fatalf("expected first parseable subtitle language suffix, got %q", got) - } -} - -func TestBuildUnit3DDataAddsSHRINumericIDs(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "SHRI", - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - Region: "3", - Distributor: "42", - Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if got := data["region_id"]; got != "3" { - t.Fatalf("expected SHRI region_id=3, got %q", got) - } - if got := data["distributor_id"]; got != "42" { - t.Fatalf("expected SHRI distributor_id=42, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerACM(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "BDMV", - UHD: "UHD", - SourceSize: 60 * (1 << 30), - } - got, err := resolveUnit3DTypeIDForTracker("ACM", meta) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "2" { - t.Fatalf("expected ACM UHD 66 type_id=2, got %q", got) - } -} - -func TestResolveUnit3DResolutionIDForTrackerACM(t *testing.T) { - meta := api.PreparedMetadata{Release: api.ReleaseInfo{Resolution: "1080i"}} - if got := resolveUnit3DResolutionIDForTracker("ACM", meta); got != "2" { - t.Fatalf("expected ACM 1080i resolution_id=2, got %q", got) - } -} - -func TestBuildUnit3DDataACMKeywordsAndNumericIDs(t *testing.T) { - req := trackers.UploadRequest{ - Tracker: "ACM", - Meta: api.PreparedMetadata{ - ReleaseName: "Movie.2025.1080p.WEB-DL.DD5.1.H264-GRP", - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Type: "WEBDL", - Region: "3", - Distributor: "42", - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Keywords: "one, two words, three,four,five,six,seven,eight,nine,ten,eleven,twelve"}, - }, - Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - }, - } - - data, err := buildUnit3DData(req, "name", "desc", "mi", "") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := data["keywords"]; got != "one, three, four, five, six, seven, eight, nine, ten, eleven" { - t.Fatalf("unexpected ACM keywords %q", got) - } - if got := data["region_id"]; got != "3" { - t.Fatalf("expected ACM region_id=3, got %q", got) - } - if got := data["distributor_id"]; got != "42" { - t.Fatalf("expected ACM distributor_id=42, got %q", got) - } -} - -func TestBuildUnit3DNameACM(t *testing.T) { - meta := api.PreparedMetadata{ - ReleaseName: "Movie.2024.1080p.BluRay.REMUX.H.265.DD+ 5.1 Atmos-GRP", - Audio: "DD+ 5.1", - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{Title: "Movie", OriginalTitle: "Original Movie"}, - }, - SubtitleLanguages: []string{"Japanese"}, - } - got := buildUnit3DName("ACM", meta, config.TrackerConfig{}) - if !strings.Contains(got, "Movie / Original Movie") { - t.Fatalf("expected ACM original title injection, got %q", got) - } - if strings.Contains(got, "H.265") || strings.Contains(got, " Atmos") { - t.Fatalf("expected ACM codec/audio cleanup, got %q", got) - } - if !strings.Contains(got, "[Jpn subs only]") { - t.Fatalf("expected ACM subtitle suffix, got %q", got) - } -} - -func TestBuildUnit3DNameULCXRemovesHybridFromWebDV(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - ReleaseName: "Movie 2026 Hybrid 1080p WEB-DL DDP5.1 DV H.265-GRP", - Type: "WEBDL", - Edition: "Hybrid", - WebDV: true, - } - got := buildUnit3DName("ULCX", meta, config.TrackerConfig{}) - if strings.Contains(got, "Hybrid") { - t.Fatalf("expected Hybrid removed for ULCX WEB-DL WebDV, got %q", got) - } -} - -func TestBuildUnit3DNameRHDBuildsFromTMDBWhenBaseNameBlank(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - Type: "WEBDL", - Tag: "-GRP", - Audio: "DD+ 5.1", - VideoEncode: "H.264", - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{ - Resolution: "1080p", - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - Year: 2025, - LocalizedTitles: map[string]string{"de": "Beispiel Film"}, - }, - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - want := "Beispiel Film 2025 GERMAN 1080p WEB-DL DD+ 5.1 H.264-GRP" - if got != want { - t.Fatalf("expected RHD TMDB-derived name %q, got %q", want, got) - } -} - -func TestBuildUnit3DNameRHDUsesTMDBTitleFallback(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - Type: "WEBDL", - Tag: "-GRP", - AudioLanguages: []string{"English"}, - Release: api.ReleaseInfo{ - Resolution: "720p", - }, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{ - Title: "TMDB Title", - Year: 2024, - }, - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - if !strings.HasPrefix(got, "TMDB Title 2024 ENGLISH 720p WEB-DL") { - t.Fatalf("expected RHD TMDB title fallback, got %q", got) - } -} - -func TestBuildUnit3DNameRHDFullDiscOmitsLanguageTag(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - ReleaseName: "Movie 2024 1080p Blu-ray AVC DTS-HD MA 5.1-GRP", - Type: "DISC", - Region: "GER", - Tag: "-GRP", - Audio: "DTS-HD MA 5.1", - VideoCodec: "AVC", - AudioLanguages: []string{"German", "English"}, - Release: api.ReleaseInfo{ - Title: "Movie", - Year: 2024, - Resolution: "1080p", - Source: "Blu-ray", - Size: "BD50", - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - want := "Movie 2024 1080p COMPLETE GER Blu-ray BD50 DTS-HD MA 5.1 AVC-GRP" - if got != want { - t.Fatalf("expected RHD full-disc name %q, got %q", want, got) - } - if strings.Contains(got, "GERMAN") || strings.Contains(got, " DL") || strings.Contains(got, " ML") { - t.Fatalf("expected RHD full-disc name to omit language tag, got %q", got) - } -} - -func TestBuildUnit3DNameRHDFullDiscUsesDiscTypeWhenTypeEmpty(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - mediaInfoPath := filepath.Join(dir, "mediainfo.txt") - if err := os.WriteFile(mediaInfoPath, []byte("General\nComplete name: Movie"), 0o600); err != nil { - t.Fatalf("write mediainfo fixture: %v", err) - } - torrentPath := filepath.Join(dir, "movie.torrent") - if err := os.WriteFile(torrentPath, []byte("torrent"), 0o600); err != nil { - t.Fatalf("write torrent fixture: %v", err) - } - - meta := api.PreparedMetadata{ - ReleaseName: "Movie.2024.1080p.COMPLETE.Blu-ray.BD50.DTS-HD.MA.5.1.AVC-GRP", - DiscType: " bdmv ", - Region: "GER", - Tag: "-GRP", - Audio: "DTS-HD MA 5.1", - VideoCodec: "AVC", - AudioLanguages: []string{"German", "English"}, - ValidMediaInfoSettings: true, - MediaInfoTextPath: mediaInfoPath, - TorrentPath: torrentPath, - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - Release: api.ReleaseInfo{ - Title: "Movie", - Year: 2024, - Resolution: "1080p", - Source: "Blu-ray", - Size: "BD50", - }, - } - - want := "Movie 2024 1080p COMPLETE GER Blu-ray BD50 DTS-HD MA 5.1 AVC-GRP" - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - if got != want { - t.Fatalf("expected RHD DiscType-only full-disc name %q, got %q", want, got) - } - if strings.Contains(got, "GERMAN") || strings.Contains(got, " DL") || strings.Contains(got, " ML") { - t.Fatalf("expected RHD DiscType-only full-disc name to omit language tag, got %q", got) - } - - entry, err := buildUploadDryRunUnit3D(context.Background(), trackers.UploadRequest{ - Tracker: "RHD", - Meta: meta, - TrackerConfig: config.TrackerConfig{ - APIKey: "test-key", - }, - Assets: &trackers.DescriptionAssets{ - Description: "description", - Final: true, - }, - }) - if err != nil { - t.Fatalf("build RHD dry-run: %v", err) - } - if entry.ReleaseName != want { - t.Fatalf("expected RHD dry-run release name %q, got %q", want, entry.ReleaseName) - } - if entry.Payload["name"] != want { - t.Fatalf("expected RHD dry-run payload name %q, got %q", want, entry.Payload["name"]) - } -} - -func TestResolveRHDTypeAndSourcePreservesExistingTypeOrdering(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - meta api.PreparedMetadata - want []string - }{ - { - name: "webdl", - meta: api.PreparedMetadata{Type: "WEBDL"}, - want: []string{"WEB-DL"}, - }, - { - name: "encode", - meta: api.PreparedMetadata{Type: "ENCODE", Source: "Blu-ray"}, - want: []string{"Blu-ray"}, - }, - { - name: "remux", - meta: api.PreparedMetadata{Type: "REMUX", Source: "Blu-ray"}, - want: []string{"Blu-ray", "REMUX"}, - }, - { - name: "disc type populated", - meta: api.PreparedMetadata{ - Type: "DISC", - Region: "GER", - Release: api.ReleaseInfo{Source: "Blu-ray", Size: "BD50"}, - }, - want: []string{"COMPLETE", "GER", "Blu-ray", "BD50"}, - }, - { - name: "empty type non disc", - meta: api.PreparedMetadata{}, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := resolveRHDTypeAndSource(tt.meta) - if strings.Join(got, "|") != strings.Join(tt.want, "|") { - t.Fatalf("expected %v, got %v", tt.want, got) - } - }) - } -} - -func TestBuildUnit3DNameRHDDetectsMarkerTokensWithBroadDelimiters(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - ReleaseName: "Marker.Movie.2024.[INTERNAL].(UPSCALED).1080p.WEB-DL.DDP5.1.H.264-GRP", - Type: "WEBDL", - Tag: "-GRP", - Audio: "DDP5.1", - VideoEncode: "H.264", - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{ - Title: "Marker Movie", - Year: 2024, - Resolution: "1080p", - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - want := "Marker Movie 2024 GERMAN 1080p UPSCALE WEB-DL DDP5.1 H.264 iNTERNAL-GRP" - if got != want { - t.Fatalf("expected RHD marker tokens with broad delimiters, got %q", got) - } -} - -func TestBuildUnit3DNameRHDIgnoresMarkerSubstringsAndGroupTag(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - ReleaseName: "Marker.Movie.2024.Regradedness.Internalized.Lineage.1080p.WEB-DL.DDP5.1.H.264-LD", - Type: "WEBDL", - Tag: "-LD", - Audio: "DDP5.1", - VideoEncode: "H.264", - AudioLanguages: []string{"English"}, - Release: api.ReleaseInfo{ - Title: "Marker Movie", - Year: 2024, - Resolution: "1080p", - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - for _, marker := range []string{"REGRADED", "UPSCALE", "iNTERNAL", "DUBBED"} { - if strings.Contains(got, marker) { - t.Fatalf("expected marker substring/group tag not to emit %s, got %q", marker, got) - } - } -} - -func TestBuildUnit3DNameRHDEmitsPreparedHDR(t *testing.T) { - t.Parallel() - - meta := api.PreparedMetadata{ - Type: "WEBDL", - Tag: "-GRP", - Audio: "DDP5.1", - HDR: "DV HDR", - VideoEncode: "H.265", - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{ - Title: "HDR Movie", - Year: 2026, - Resolution: "2160p", - }, - } - - got := buildUnit3DName("RHD", meta, config.TrackerConfig{}) - want := "HDR Movie 2026 GERMAN 2160p WEB-DL DDP5.1 DV HDR H.265-GRP" - if got != want { - t.Fatalf("expected RHD name to include prepared HDR value %q, got %q", want, got) - } -} - -func TestResolveRHDLanguageCountsUniqueValidAudioLanguages(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - language []string - want string - }{ - { - name: "blank ignored", - language: []string{"", "French", " "}, - want: "FRENCH", - }, - { - name: "duplicate aliases ignored", - language: []string{"English", "eng", "English"}, - want: "ENGLISH", - }, - { - name: "german aliases ignored", - language: []string{"German", "deu", "de-DE"}, - want: "GERMAN", - }, - { - name: "dual real languages tagged", - language: []string{"English", "French"}, - want: "ENGLISH DL", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := resolveRHDLanguage(api.PreparedMetadata{AudioLanguages: tt.language}) - if got != tt.want { - t.Fatalf("expected %q, got %q", tt.want, got) - } - }) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTIKForeignMovie(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{OriginalLanguage: "fr"}, - }, - } - if got := resolveUnit3DCategoryIDForTracker("TIK", meta); got != "3" { - t.Fatalf("expected TIK foreign movie category_id=3, got %q", got) - } -} - -func TestEnsureUnit3DDVDVOBDescriptionAppendsForOverride(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "DVD", - DVDVOBMediaInfoText: "VOB_CONTENT", - } - - result := ensureUnit3DDVDVOBDescription("override description", meta) - if !strings.Contains(result, "override description") { - t.Fatalf("expected override description to be kept, got %q", result) - } - if !strings.Contains(result, "[spoiler=VOB MediaInfo][code]VOB_CONTENT[/code][/spoiler]") { - t.Fatalf("expected dvd vob mediainfo block, got %q", result) - } -} - -func TestEnsureUnit3DDVDVOBDescriptionAvoidsDuplicate(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "DVD", - DVDVOBMediaInfoText: "VOB_CONTENT", - } - block := descriptionunit3d.DVDVOBMediaInfoBlock(meta) - description := "override description\n\n" + block - - result := ensureUnit3DDVDVOBDescription(description, meta) - if strings.Count(result, "[spoiler=VOB MediaInfo][code]") != 1 { - t.Fatalf("expected single dvd vob mediainfo block, got %q", result) - } -} - -func TestLoadUnit3DMediaUsesIFOTextPathForDVD(t *testing.T) { - root := t.TempDir() - miPath := filepath.Join(root, "mediainfo.txt") - if err := os.WriteFile(miPath, []byte("IFO_MEDIAINFO"), 0o600); err != nil { - t.Fatalf("write mediainfo: %v", err) - } - - meta := api.PreparedMetadata{ - DiscType: "DVD", - MediaInfoTextPath: miPath, - DVDVOBMediaInfoText: "VOB_MEDIAINFO", - } - - mediainfo, bdinfo, err := loadUnit3DMedia(meta, "", api.NopLogger{}) - if err != nil { - t.Fatalf("load unit3d media: %v", err) - } - if mediainfo != "IFO_MEDIAINFO" { - t.Fatalf("expected IFO mediainfo from MediaInfoTextPath, got %q", mediainfo) - } - if bdinfo != "" { - t.Fatalf("expected empty bdinfo for DVD fallback path, got %q", bdinfo) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTIKAsianMovie(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - AudioLanguages: []string{"English"}, - SubtitleLanguages: []string{"English"}, - ExternalMetadata: api.ExternalMetadata{ - TMDB: &api.TMDBMetadata{OriginalLanguage: "en", OriginCountry: []string{"JP"}}, - }, - } - if got := resolveUnit3DCategoryIDForTracker("TIK", meta); got != "6" { - t.Fatalf("expected TIK asian movie category_id=6, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTIKOperaTV(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "TV"}, - AudioLanguages: []string{"English"}, - SubtitleLanguages: []string{"English"}, - Release: api.ReleaseInfo{Genre: "Opera"}, - } - if got := resolveUnit3DCategoryIDForTracker("TIK", meta); got != "5" { - t.Fatalf("expected TIK opera tv category_id=5, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTIKOverrideForeignMovie(t *testing.T) { - forceForeign := true - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - TrackerSiteOverrides: api.TrackerSiteOverrides{ - TIK: api.TIKOverrides{ - Foreign: &forceForeign, - }, - }, - } - if got := resolveUnit3DCategoryIDForTracker("TIK", meta); got != "3" { - t.Fatalf("expected TIK foreign override movie category_id=3, got %q", got) - } -} - -func TestResolveUnit3DCategoryIDForTrackerTIKOverrideAsianMovie(t *testing.T) { - forceAsian := true - forceForeign := false - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "MOVIE"}, - AudioLanguages: []string{"English"}, - SubtitleLanguages: []string{"English"}, - TrackerSiteOverrides: api.TrackerSiteOverrides{ - TIK: api.TIKOverrides{ - Foreign: &forceForeign, - Asian: &forceAsian, - }, - }, - } - if got := resolveUnit3DCategoryIDForTracker("TIK", meta); got != "6" { - t.Fatalf("expected TIK asian override movie category_id=6, got %q", got) - } -} - -func TestResolveUnit3DTypeIDForTrackerTIKOverrideDiscType(t *testing.T) { - discType := "BD66" - meta := api.PreparedMetadata{ - DiscType: "BDMV", - TrackerSiteOverrides: api.TrackerSiteOverrides{ - TIK: api.TIKOverrides{ - DiscType: &discType, - }, - }, - } - got := resolveUnit3DTIKTypeID(meta) - if got != "4" { - t.Fatalf("expected TIK BD66 override type_id=4, got %q", got) - } -} diff --git a/internal/trackers/impl/unit3d/utp.go b/internal/trackers/impl/unit3d/utp.go deleted file mode 100644 index cf762068..00000000 --- a/internal/trackers/impl/unit3d/utp.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteUTPProfile() unit3DSiteProfile { - return unit3DSiteProfile{ - resolveTypeID: resolveUnit3DUTPTypeID, - resolveResolutionID: resolveUnit3DUTPResolutionID, - } -} - -func resolveUnit3DUTPTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "ENCODE": "3", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - } - typeID := mapping[inferUnit3DType(meta)] - if typeID == "" { - return "3" - } - return typeID -} - -func resolveUnit3DUTPResolutionID(meta api.PreparedMetadata) string { - resolution := resolveResolution(meta) - mapping := map[string]string{ - "4320p": "1", - "2160p": "2", - "1080p": "3", - "1080i": "4", - } - if value, ok := mapping[resolution]; ok { - return value - } - return "11" -} diff --git a/internal/trackers/impl/unit3d/wrap_errors.go b/internal/trackers/impl/unit3d/wrap_errors.go deleted file mode 100644 index 6ffe8432..00000000 --- a/internal/trackers/impl/unit3d/wrap_errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "fmt" - -func wrapTrackerResult[T any](value T, err error) (T, error) { - if err != nil { - return value, fmt.Errorf("trackers: %w", err) - } - return value, nil -} diff --git a/internal/trackers/impl/unit3d/yus.go b/internal/trackers/impl/unit3d/yus.go deleted file mode 100644 index 4af5ea18..00000000 --- a/internal/trackers/impl/unit3d/yus.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -func siteYUSProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DYUSTypeID} -} - -func resolveUnit3DYUSTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "17", - "REMUX": "2", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - "ENCODE": "3", - } - return mapping[inferUnit3DType(meta)] -} diff --git a/internal/trackers/impl/unit3d/znth.go b/internal/trackers/impl/unit3d/znth.go deleted file mode 100644 index 73b83be7..00000000 --- a/internal/trackers/impl/unit3d/znth.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3d - -import "github.com/autobrr/upbrr/pkg/api" - -// siteZNTHProfile registers ZNTH's custom Unit3D type-id resolver. -func siteZNTHProfile() unit3DSiteProfile { - return unit3DSiteProfile{resolveTypeID: resolveUnit3DZNTHTypeID} -} - -// resolveUnit3DZNTHTypeID maps inferred release types to ZNTH type_id values. -// Unsupported or unknown types return an empty id for the shared Unit3D resolver -// to reject. -func resolveUnit3DZNTHTypeID(meta api.PreparedMetadata) string { - mapping := map[string]string{ - "DISC": "1", - "REMUX": "2", - "ENCODE": "3", - "DVDRIP": "11", - "WEBDL": "4", - "WEBRIP": "5", - "HDTV": "6", - } - return mapping[inferUnit3DType(meta)] -} diff --git a/internal/trackers/impl/unit3d_profiles_test.go b/internal/trackers/impl/unit3d_profiles_test.go new file mode 100644 index 00000000..32443300 --- /dev/null +++ b/internal/trackers/impl/unit3d_profiles_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package impl + +import ( + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/a4k" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/aither" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/blu" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ihd" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/itt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/ldu" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/lst" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/oe" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/otw" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/pt" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/r4e" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/rf" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/shri" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/stc" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/tlz" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/tos" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/utp" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/yus" + "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/sites/znth" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestExplicitUnit3DProfilesPreserveResolvers(t *testing.T) { + tests := []struct { + name string + profile unit3d.Profile + meta api.UploadSubject + want string + }{ + { + name: "A4K rejects WEBRIP", + profile: a4k.Profile(), + meta: api.UploadSubject{Type: "WEBRIP"}, + want: "", + }, + { + name: "ITT DLMux", + profile: itt.Profile(), + meta: api.UploadSubject{ReleaseName: "Example.Release.2026.1080p.DLMux-GRP", Type: "WEBDL"}, + want: "27", + }, + { + name: "OE HEVC", + profile: oe.Profile(), + meta: api.UploadSubject{Type: "WEBRIP", VideoCodec: "HEVC"}, + want: "10", + }, + { + name: "OTW DVD", + profile: otw.Profile(), + meta: api.UploadSubject{DiscType: "DVD", Type: "REMUX"}, + want: "7", + }, + { + name: "STC pack", + profile: stc.Profile(), + meta: api.UploadSubject{ + Type: "WEBDL", + TVPack: true, + Release: api.ReleaseInfo{Resolution: "1080p"}, + }, + want: "13", + }, + { + name: "TLZ pack", + profile: tlz.Profile(), + meta: api.UploadSubject{Identity: api.ExternalIdentity{Category: "TV"}, TVPack: true}, + want: "4", + }, + { + name: "ZNTH DVDRIP", + profile: znth.Profile(), + meta: api.UploadSubject{Type: "DVDRIP"}, + want: "11", + }, + { + name: "RF encode", + profile: rf.Profile(), + meta: api.UploadSubject{Type: "ENCODE"}, + want: "41", + }, + { + name: "YUS disc", + profile: yus.Profile(), + meta: api.UploadSubject{Type: "DISC"}, + want: "17", + }, + { + name: "BLU encode", + profile: blu.Profile(), + meta: api.UploadSubject{Type: "ENCODE"}, + want: "12", + }, + { + name: "PT WEBRIP", + profile: pt.Profile(), + meta: api.UploadSubject{Type: "WEBRIP"}, + want: "39", + }, + { + name: "SHRI remux", + profile: shri.Profile(), + meta: api.UploadSubject{Type: "REMUX"}, + want: "7", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.profile.Site.ResolveTypeID(test.meta); got != test.want { + t.Fatalf("type ID = %q, want %q", got, test.want) + } + }) + } + if got := rf.Profile().Site.ResolveResolutionID(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "1440p"}}); got != "10" { + t.Fatalf("RF resolution ID = %q, want 10", got) + } + if got := utp.Profile().Site.ResolveResolutionID(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "720p"}}); got != "11" { + t.Fatalf("UTP resolution ID = %q, want 11", got) + } + if got := blu.Profile().Site.ResolveResolutionID(api.UploadSubject{Release: api.ReleaseInfo{Resolution: "2160p"}}); got != "1" { + t.Fatalf("BLU resolution ID = %q, want 1", got) + } + if got := ihd.Profile().Site.ResolveCategoryID(api.UploadSubject{Identity: api.ExternalIdentity{Category: "MOVIE"}, Anime: true}); got != "4" { + t.Fatalf("IHD category ID = %q, want 4", got) + } + if got := tos.Profile().Site.ResolveCategoryID(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "TV"}, + TVPack: true, + Tag: "-vostfr", + }); got != "9" { + t.Fatalf("TOS category ID = %q, want 9", got) + } + if got := ldu.Profile().Site.ResolveCategoryID(api.UploadSubject{Identity: api.ExternalIdentity{Category: "TV"}, Anime: true}); got != "9" { + t.Fatalf("LDU anime category ID = %q, want 9", got) + } + if got := ldu.Profile().Site.ResolveCategoryID(api.UploadSubject{ + Identity: api.ExternalIdentity{Category: "MOVIE"}, + AudioLanguages: []string{"Portuguese"}, + SubtitleLanguages: []string{"Portuguese"}, + }); got != "22" { + t.Fatalf("LDU non-English category ID = %q, want 22", got) + } + data := map[string]string{} + pt.Profile().Site.ApplyAdditionalPayload(trackers.PreparationInput{Meta: api.UploadSubject{AudioLanguages: []string{"Portuguese"}, SubtitleLanguages: []string{"PT-BR", "English"}}}, data) + if data["audio_pt"] != "1" || data["legenda_pt"] != "0" { + t.Fatalf("PT language payload = %#v", data) + } + shriData := map[string]string{} + shri.Profile().Site.ApplyAdditionalPayload(trackers.PreparationInput{Meta: api.UploadSubject{Region: "3", Distributor: "42"}}, shriData) + if shriData["region_id"] != "3" || shriData["distributor_id"] != "42" { + t.Fatalf("SHRI payload = %#v", shriData) + } + description := shri.Profile().Site.FinalizeDescription("Base description", api.UploadSubject{Release: api.ReleaseInfo{Group: "island"}}) + if !strings.Contains(description, "Release Shareisland") || !strings.Contains(description, "Base description") { + t.Fatalf("SHRI description = %q", description) + } + lstData := map[string]string{} + lst.Profile().Site.ApplyAdditionalPayload(trackers.PreparationInput{TrackerConfig: config.TrackerConfig{Draft: true}, Meta: api.UploadSubject{Edition: "Director's Cut"}}, lstData) + if lstData["draft_queue_opt_in"] != "1" || lstData["edition_id"] != "2" { + t.Fatalf("LST payload = %#v", lstData) + } + aitherData := map[string]string{} + aither.Profile().Site.ApplyAdditionalPayload(trackers.PreparationInput{Meta: api.UploadSubject{HDR: "HDR10+ DV"}}, aitherData) + if aitherData["hdr10p"] != "1" || aitherData["dv"] != "1" { + t.Fatalf("AITHER HDR payload = %#v", aitherData) + } + aitherMeta := api.UploadSubject{ + ReleaseName: "Example.Release.2020.DVD.DVDRIP.AAC.XVID-GRP", + Release: api.ReleaseInfo{Year: 2020, Resolution: "480p"}, + Type: "DVDRIP", + Source: "DVD", + Audio: "AAC 2.0", + VideoEncode: "XVID", + } + if got := aither.Profile().Site.BuildName(aitherMeta, config.TrackerConfig{}); got == "" || got == aitherMeta.ReleaseName { + t.Fatalf("AITHER name = %q", got) + } + r4eMeta := api.UploadSubject{Identity: api.ExternalIdentity{Category: "TV"}, ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{GenreIDs: "99,18"}}} + if got := r4e.Profile().Site.ResolveCategoryID(r4eMeta); got != "2" { + t.Fatalf("R4E category ID = %q, want 2", got) + } +} diff --git a/internal/trackers/internal_groups.go b/internal/trackers/internal_groups.go index 3e7570b8..80e9b81b 100644 --- a/internal/trackers/internal_groups.go +++ b/internal/trackers/internal_groups.go @@ -10,7 +10,8 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) -func IsInternalGroup(cfg config.Config, tracker string, meta api.PreparedMetadata) bool { +// IsInternalGroup reports whether meta's release group is configured as internal for tracker. +func IsInternalGroup(cfg config.Config, tracker string, meta api.UploadSubject) bool { if strings.TrimSpace(meta.Tag) == "" { return false } diff --git a/internal/trackers/internal_groups_test.go b/internal/trackers/internal_groups_test.go index cb6ce6b5..2d0a6a2f 100644 --- a/internal/trackers/internal_groups_test.go +++ b/internal/trackers/internal_groups_test.go @@ -24,13 +24,13 @@ func TestIsInternalGroup(t *testing.T) { }, } - if !IsInternalGroup(cfg, "MTV", api.PreparedMetadata{Tag: "-GroupA"}) { + if !IsInternalGroup(cfg, "MTV", api.UploadSubject{Tag: "-GroupA"}) { t.Fatalf("expected group to be internal") } - if IsInternalGroup(cfg, "MTV", api.PreparedMetadata{Tag: "-Other"}) { + if IsInternalGroup(cfg, "MTV", api.UploadSubject{Tag: "-Other"}) { t.Fatalf("expected group to be non-internal") } - if IsInternalGroup(cfg, "BHD", api.PreparedMetadata{Tag: "-GroupA"}) { + if IsInternalGroup(cfg, "BHD", api.UploadSubject{Tag: "-GroupA"}) { t.Fatalf("expected missing tracker to be non-internal") } } diff --git a/internal/trackers/media.go b/internal/trackers/media.go index 69db1399..686a277a 100644 --- a/internal/trackers/media.go +++ b/internal/trackers/media.go @@ -9,23 +9,24 @@ import ( "strings" "github.com/autobrr/upbrr/internal/metadata/metautil" - "github.com/autobrr/upbrr/internal/paths" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackers/impl/commonhttp" "github.com/autobrr/upbrr/pkg/api" ) -// ReadBDInfo reads the BDInfo from the given path. -func ReadBDInfo(dbPath string, meta api.PreparedMetadata) (string, error) { +// ReadBDInfo reads the selected BDMV playlist summary from the release-scoped +// temporary directory. Missing selections and missing or unstatable artifacts +// return empty without error; directory-resolution and read failures are returned. +func ReadBDInfo(dbPath string, meta api.UploadSubject) (string, error) { tmpRoot, err := db.Subdir(dbPath, "tmp") if err != nil { return "", fmt.Errorf("trackers: resolve tmp root: %w", err) } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "", fmt.Errorf("trackers: resolve release tmp dir: %w", err) } - path := paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylist(meta)) + path := paths.BDMVSummaryPath(tmpDir, paths.PrimaryBDMVPlaylistFor(meta.SelectedBDMVPlaylists)) if strings.TrimSpace(path) == "" { return "", nil } @@ -35,13 +36,23 @@ func ReadBDInfo(dbPath string, meta api.PreparedMetadata) (string, error) { return readTextFile(path) } -// ReadBDinfoOrMediaInfo reads the BDInfo if the disc type is BDMV, otherwise it reads the MediaInfoTextPath. -func ReadBDinfoOrMediaInfo(dbPath string, meta api.PreparedMetadata) string { +// ReadBDinfoOrMediaInfo returns BDMV summary text or the first available general +// or DVD-VOB MediaInfo report. Artifact resolution and read errors are treated +// as missing text. +func ReadBDinfoOrMediaInfo(dbPath string, meta api.UploadSubject) string { if strings.EqualFold(strings.TrimSpace(meta.DiscType), "BDMV") { bdinfo, _ := ReadBDInfo(dbPath, meta) return strings.TrimSpace(bdinfo) } - return metautil.FirstNonEmptyTrimmed(commonhttp.ReadOptionalFile(meta.MediaInfoTextPath), commonhttp.ReadOptionalFile(meta.DVDVOBMediaInfoText)) + return metautil.FirstNonEmptyTrimmed(readOptionalTextFile(meta.MediaInfoTextPath), readOptionalTextFile(meta.DVDVOBMediaInfoText)) +} + +func readOptionalTextFile(path string) string { + payload, err := readTextFile(path) + if err != nil { + return "" + } + return payload } // existsFile checks if a file exists. diff --git a/internal/trackers/metadata_requirements.go b/internal/trackers/metadata_requirements.go index 6b9d2c49..b9e5023a 100644 --- a/internal/trackers/metadata_requirements.go +++ b/internal/trackers/metadata_requirements.go @@ -55,8 +55,8 @@ type MetadataRequirement struct { Scope MetadataScope // AnyOf is satisfied when at least one listed field is present and current. AnyOf []MetadataField - // Severity defaults to blocking when empty or unrecognized. - Severity api.RuleFailureSeverity + // Disposition defaults to waivable for legacy empty values. + Disposition api.RuleDisposition } // TrackerMetadataPolicy defines declarative metadata requirements for a tracker. @@ -67,71 +67,16 @@ type TrackerMetadataPolicy struct { Requirements []MetadataRequirement } -// trackerMetadataPolicies maps normalized tracker names to their required -// provider evidence; requirements within one entry are cumulative. -var trackerMetadataPolicies = map[string]TrackerMetadataPolicy{ - "PTP": {Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}, Severity: api.RuleFailureSeverityWarning}}}, - "HDB": {RequireKnownCategory: true, Requirements: []MetadataRequirement{ - {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}}, - {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly, MetadataFieldTVDBIDOnly}}, - }}, - "NBL": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTVmaze}}}}, - "ANT": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDB}}}}, - "BHD": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldIMDB}}}}, - "MTV": {RequireKnownCategory: true, Requirements: []MetadataRequirement{ - {Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}, - {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTVDBTitle}}, - }}, - "BTN": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldIMDB, MetadataFieldTVDB}}}}, - "AR": {RequireKnownCategory: true, Requirements: []MetadataRequirement{ - {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}, - {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB, MetadataFieldTVDB}}, - {Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldPoster}}, - }}, - "SPD": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}}}, - "THR": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}}}, - "TVC": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}}}, - "TL": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}}}, - "BJS": {RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB}}}}, - "AZ": multiIDMetadataPolicy(), - "CZ": multiIDMetadataPolicy(), - "PHD": multiIDMetadataPolicy(), - "CZT": {Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}}}}, -} - -// multiIDMetadataPolicy returns the shared movie and TV policy for trackers -// that accept several provider identifiers. -func multiIDMetadataPolicy() TrackerMetadataPolicy { - return TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{ - {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDBIDOnly, MetadataFieldIMDBIDOnly}}, - {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTMDBIDOnly, MetadataFieldIMDBIDOnly, MetadataFieldTVDBIDOnly}}, - }} -} - -// MetadataPolicyFor returns an independent copy of a tracker's metadata policy. -// Tracker names are case-insensitive and whitespace-trimmed. Known Unit3D -// trackers without an explicit policy require a current TMDB ID. -func MetadataPolicyFor(tracker string) (TrackerMetadataPolicy, bool) { - name := strings.ToUpper(strings.TrimSpace(tracker)) - policy, ok := trackerMetadataPolicies[name] - if !ok && IsUnit3DTracker(name) { - policy = TrackerMetadataPolicy{Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB}}}} - ok = true - } - if !ok { - return TrackerMetadataPolicy{}, false - } +func cloneMetadataPolicy(policy TrackerMetadataPolicy) TrackerMetadataPolicy { policy.Requirements = slices.Clone(policy.Requirements) for i := range policy.Requirements { policy.Requirements[i].AnyOf = slices.Clone(policy.Requirements[i].AnyOf) } - return policy, true + return policy } -// evaluateMetadataRequirements returns policy results and whether the tracker -// has a metadata policy. An evaluated policy may produce a non-nil empty slice. -func evaluateMetadataRequirements(tracker string, meta api.PreparedMetadata) ([]api.RuleFailure, bool) { - policy, ok := MetadataPolicyFor(tracker) +func evaluateMetadataRequirementsWithRegistry(registry *Registry, tracker string, meta api.RuleSubject) ([]api.RuleFailure, bool) { + policy, ok := registry.LookupMetadataPolicy(tracker) if !ok { return nil, false } @@ -139,9 +84,9 @@ func evaluateMetadataRequirements(tracker string, meta api.PreparedMetadata) ([] category := MetadataScope(strings.ToLower(strings.TrimSpace(resolveCategory(meta)))) if policy.RequireKnownCategory && category != MetadataScopeMovie && category != MetadataScopeTV { return []api.RuleFailure{{ - Rule: "require_metadata_category", - Reason: "missing category required to select tracker metadata requirements", - Severity: api.RuleFailureSeverityBlocking, + Rule: "require_metadata_category", + Reason: "missing category required to select tracker metadata requirements", + Disposition: metadataCategoryDisposition(policy), }}, true } @@ -153,7 +98,7 @@ func evaluateMetadataRequirements(tracker string, meta api.PreparedMetadata) ([] if metadataRequirementPresent(requirement.AnyOf, meta) { continue } - severity := api.NormalizeRuleFailureSeverity(requirement.Severity) + disposition := api.NormalizeRuleDisposition(requirement.Disposition) rule := "require_metadata_id" reason := "missing required " + metadataFieldList(requirement.AnyOf) switch { @@ -163,17 +108,40 @@ func evaluateMetadataRequirements(tracker string, meta api.PreparedMetadata) ([] case slices.Contains(requirement.AnyOf, MetadataFieldPoster): rule = "require_metadata_poster" reason = "missing required metadata poster" - case severity == api.RuleFailureSeverityWarning: + case disposition == api.RuleDispositionAdvisory: reason = "missing recommended IMDb ID; PTP upload remains allowed" } - failures = append(failures, api.RuleFailure{Rule: rule, Reason: reason, Severity: severity}) + failures = append(failures, api.RuleFailure{ + Rule: rule, + Reason: reason, + Disposition: disposition, + }) } return failures, true } +// metadataCategoryDisposition prevents a waivable missing-category result from +// bypassing a category-scoped strict metadata requirement. +func metadataCategoryDisposition(policy TrackerMetadataPolicy) api.RuleDisposition { + if len(policy.Requirements) == 0 { + return api.RuleDispositionWaivable + } + disposition := api.RuleDispositionAdvisory + for _, requirement := range policy.Requirements { + requirementDisposition := api.NormalizeRuleDisposition(requirement.Disposition) + if requirementDisposition == api.RuleDispositionStrict { + return api.RuleDispositionStrict + } + if requirementDisposition == api.RuleDispositionWaivable { + disposition = api.RuleDispositionWaivable + } + } + return disposition +} + // metadataRequirementPresent reports whether any alternative field satisfies // a requirement. -func metadataRequirementPresent(fields []MetadataField, meta api.PreparedMetadata) bool { +func metadataRequirementPresent(fields []MetadataField, meta api.RuleSubject) bool { for _, field := range fields { if metadataFieldPresent(field, meta) { return true @@ -184,17 +152,17 @@ func metadataRequirementPresent(fields []MetadataField, meta api.PreparedMetadat // metadataFieldPresent accepts only IDs and provider data scoped to the current // source; an empty scope remains compatible with legacy unscoped metadata. -func metadataFieldPresent(field MetadataField, meta api.PreparedMetadata) bool { - idsCurrent := sourceMatches(meta.ExternalIDs.SourcePath, meta.SourcePath) +func metadataFieldPresent(field MetadataField, meta api.RuleSubject) bool { + idsCurrent := sourceMatches(meta.Identity.SourcePath, meta.SourcePath) switch field { case MetadataFieldTMDBIDOnly: - return idsCurrent && meta.ExternalIDs.TMDBID > 0 + return idsCurrent && meta.Identity.TMDBID > 0 case MetadataFieldIMDBIDOnly: - return idsCurrent && meta.ExternalIDs.IMDBID > 0 + return idsCurrent && meta.Identity.IMDBID > 0 case MetadataFieldTVDBIDOnly: - return idsCurrent && meta.ExternalIDs.TVDBID > 0 + return idsCurrent && meta.Identity.TVDBID > 0 case MetadataFieldTVmazeIDOnly: - return idsCurrent && meta.ExternalIDs.TVmazeID > 0 + return idsCurrent && meta.Identity.TVmazeID > 0 case MetadataFieldTMDB: return matchingTMDBMetadata(meta) case MetadataFieldIMDB: @@ -211,57 +179,57 @@ func metadataFieldPresent(field MetadataField, meta api.PreparedMetadata) bool { return false } -func matchingTMDBMetadata(meta api.PreparedMetadata) bool { - value := meta.ExternalMetadata.TMDB - return providerMetadataCurrent(meta) && value != nil && meta.ExternalIDs.TMDBID > 0 && - value.TMDBID == meta.ExternalIDs.TMDBID && strings.TrimSpace(value.Title) != "" +func matchingTMDBMetadata(meta api.RuleSubject) bool { + value := meta.ProviderMetadata.TMDB + return providerMetadataCurrent(meta) && value != nil && meta.Identity.TMDBID > 0 && + value.TMDBID == meta.Identity.TMDBID && strings.TrimSpace(value.Title) != "" } -func matchingIMDBMetadata(meta api.PreparedMetadata) bool { - value := meta.ExternalMetadata.IMDB - return providerMetadataCurrent(meta) && value != nil && meta.ExternalIDs.IMDBID > 0 && - value.IMDBID == meta.ExternalIDs.IMDBID && strings.TrimSpace(value.Title) != "" +func matchingIMDBMetadata(meta api.RuleSubject) bool { + value := meta.ProviderMetadata.IMDB + return providerMetadataCurrent(meta) && value != nil && meta.Identity.IMDBID > 0 && + value.IMDBID == meta.Identity.IMDBID && strings.TrimSpace(value.Title) != "" } -func matchingTVDBMetadata(meta api.PreparedMetadata) bool { - value := meta.ExternalMetadata.TVDB - return providerMetadataCurrent(meta) && value != nil && meta.ExternalIDs.TVDBID > 0 && - value.TVDBID == meta.ExternalIDs.TVDBID && +func matchingTVDBMetadata(meta api.RuleSubject) bool { + value := meta.ProviderMetadata.TVDB + return providerMetadataCurrent(meta) && value != nil && meta.Identity.TVDBID > 0 && + value.TVDBID == meta.Identity.TVDBID && (strings.TrimSpace(value.NameEnglish) != "" || strings.TrimSpace(value.Name) != "") } -func matchingTVmazeMetadata(meta api.PreparedMetadata) bool { - value := meta.ExternalMetadata.TVmaze - return providerMetadataCurrent(meta) && value != nil && meta.ExternalIDs.TVmazeID > 0 && - value.TVmazeID == meta.ExternalIDs.TVmazeID && strings.TrimSpace(value.Name) != "" +func matchingTVmazeMetadata(meta api.RuleSubject) bool { + value := meta.ProviderMetadata.TVmaze + return providerMetadataCurrent(meta) && value != nil && meta.Identity.TVmazeID > 0 && + value.TVmazeID == meta.Identity.TVmazeID && strings.TrimSpace(value.Name) != "" } // matchingMetadataPoster reports whether any current matching provider snapshot // supplies poster artwork, independently of the provider used for identity. -func matchingMetadataPoster(meta api.PreparedMetadata) bool { +func matchingMetadataPoster(meta api.RuleSubject) bool { if !providerMetadataCurrent(meta) { return false } - if value := meta.ExternalMetadata.TMDB; value != nil && meta.ExternalIDs.TMDBID > 0 && - value.TMDBID == meta.ExternalIDs.TMDBID && strings.TrimSpace(value.Poster) != "" { + if value := meta.ProviderMetadata.TMDB; value != nil && meta.Identity.TMDBID > 0 && + value.TMDBID == meta.Identity.TMDBID && strings.TrimSpace(value.Poster) != "" { return true } - if value := meta.ExternalMetadata.IMDB; value != nil && meta.ExternalIDs.IMDBID > 0 && - value.IMDBID == meta.ExternalIDs.IMDBID && strings.TrimSpace(value.Cover) != "" { + if value := meta.ProviderMetadata.IMDB; value != nil && meta.Identity.IMDBID > 0 && + value.IMDBID == meta.Identity.IMDBID && strings.TrimSpace(value.Cover) != "" { return true } - if value := meta.ExternalMetadata.TVDB; value != nil && meta.ExternalIDs.TVDBID > 0 && - value.TVDBID == meta.ExternalIDs.TVDBID && strings.TrimSpace(value.Poster) != "" { + if value := meta.ProviderMetadata.TVDB; value != nil && meta.Identity.TVDBID > 0 && + value.TVDBID == meta.Identity.TVDBID && strings.TrimSpace(value.Poster) != "" { return true } - value := meta.ExternalMetadata.TVmaze - return value != nil && meta.ExternalIDs.TVmazeID > 0 && value.TVmazeID == meta.ExternalIDs.TVmazeID && + value := meta.ProviderMetadata.TVmaze + return value != nil && meta.Identity.TVmazeID > 0 && value.TVmazeID == meta.Identity.TVmazeID && (strings.TrimSpace(value.Poster) != "" || strings.TrimSpace(value.PosterMedium) != "") } -func providerMetadataCurrent(meta api.PreparedMetadata) bool { - return sourceMatches(meta.ExternalIDs.SourcePath, meta.SourcePath) && - sourceMatches(meta.ExternalMetadata.SourcePath, meta.SourcePath) +func providerMetadataCurrent(meta api.RuleSubject) bool { + return sourceMatches(meta.Identity.SourcePath, meta.SourcePath) && + sourceMatches(meta.ProviderMetadata.SourcePath, meta.SourcePath) } // sourceMatches reports whether data is unscoped or belongs to the current diff --git a/internal/trackers/metadata_requirements_test.go b/internal/trackers/metadata_requirements_test.go index b6b9d3bf..a119e649 100644 --- a/internal/trackers/metadata_requirements_test.go +++ b/internal/trackers/metadata_requirements_test.go @@ -13,80 +13,411 @@ import ( func TestMetadataRequirementMatrix(t *testing.T) { t.Parallel() + registry := newMetadataRegistry(t) tests := []struct { name string tracker string category string - ids api.ExternalIDs - metadata api.ExternalMetadata + ids api.ExternalIdentity + metadata api.SourceScopedMetadata warning bool fail bool }{ - {name: "unit3d tmdb", tracker: "AITHER", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}}, - {name: "unit3d id only", tracker: "AITHER", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, - {name: "unit3d missing", tracker: "AITHER", fail: true}, - {name: "ptp imdb", tracker: "PTP", ids: api.ExternalIDs{IMDBID: 1234567}}, - {name: "ptp warning", tracker: "PTP", warning: true}, - {name: "hdb movie imdb", tracker: "HDB", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}}, - {name: "hdb movie tvdb rejected", tracker: "HDB", category: "movie", ids: api.ExternalIDs{TVDBID: 2}, fail: true}, - {name: "hdb tv imdb", tracker: "HDB", category: "tv", ids: api.ExternalIDs{IMDBID: 1234567}}, - {name: "hdb tv tvdb", tracker: "HDB", category: "tv", ids: api.ExternalIDs{TVDBID: 2}}, - {name: "hdb tv missing", tracker: "HDB", category: "tv", fail: true}, - {name: "nbl tvmaze", tracker: "NBL", category: "tv", ids: api.ExternalIDs{TVmazeID: 3}, metadata: api.ExternalMetadata{TVmaze: &api.TVmazeMetadata{TVmazeID: 3, Name: "Example Series"}}}, - {name: "nbl id only", tracker: "NBL", category: "tv", ids: api.ExternalIDs{TVmazeID: 3}, fail: true}, - {name: "nbl wrong provider", tracker: "NBL", category: "tv", ids: api.ExternalIDs{IMDBID: 1234567}, fail: true}, - {name: "ant tmdb", tracker: "ANT", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}}, - {name: "ant imdb rejected", tracker: "ANT", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, fail: true}, - {name: "bhd imdb", tracker: "BHD", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}}, - {name: "bhd imdb id only", tracker: "BHD", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, fail: true}, - {name: "bhd tmdb rejected", tracker: "BHD", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, fail: true}, - {name: "mtv movie imdb", tracker: "MTV", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}}, - {name: "mtv movie tmdb", tracker: "MTV", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}}, - {name: "mtv movie id only", tracker: "MTV", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, - {name: "mtv tv complete", tracker: "MTV", category: "tv", ids: api.ExternalIDs{TMDBID: 1, TVDBID: 2}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}, TVDB: &api.TVDBMetadata{TVDBID: 2, NameEnglish: "Example Series"}}}, - {name: "mtv tv blank title", tracker: "MTV", category: "tv", ids: api.ExternalIDs{TMDBID: 1, TVDBID: 2}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}, TVDB: &api.TVDBMetadata{TVDBID: 2}}, fail: true}, - {name: "mtv tv mismatched title metadata", tracker: "MTV", category: "tv", ids: api.ExternalIDs{TMDBID: 1, TVDBID: 2}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}, TVDB: &api.TVDBMetadata{TVDBID: 3, Name: "Example Series"}}, fail: true}, - {name: "mtv tv tvdb identity rejected", tracker: "MTV", category: "tv", ids: api.ExternalIDs{TVDBID: 2}, metadata: api.ExternalMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series"}}, fail: true}, - {name: "btn imdb", tracker: "BTN", category: "tv", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}}}, - {name: "btn tvdb", tracker: "BTN", category: "tv", ids: api.ExternalIDs{TVDBID: 2}, metadata: api.ExternalMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series"}}}, - {name: "btn id only", tracker: "BTN", category: "tv", ids: api.ExternalIDs{TVDBID: 2}, fail: true}, - {name: "btn tmdb rejected", tracker: "BTN", category: "tv", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, - {name: "ar movie imdb", tracker: "AR", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release", Cover: "https://img.example/poster.jpg"}}}, - {name: "ar movie tmdb", tracker: "AR", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release", Poster: "https://img.example/poster.jpg"}}}, - {name: "ar movie missing poster", tracker: "AR", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, fail: true}, - {name: "ar movie tvdb rejected", tracker: "AR", category: "movie", ids: api.ExternalIDs{TVDBID: 2}, fail: true}, - {name: "ar tv imdb", tracker: "AR", category: "tv", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series", Cover: "https://img.example/poster.jpg"}}}, - {name: "ar tv tmdb", tracker: "AR", category: "tv", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series", Poster: "https://img.example/poster.jpg"}}}, - {name: "ar tv tvdb", tracker: "AR", category: "tv", ids: api.ExternalIDs{TVDBID: 2}, metadata: api.ExternalMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series", Poster: "https://img.example/poster.jpg"}}}, - {name: "ar tv tvmaze rejected", tracker: "AR", category: "tv", ids: api.ExternalIDs{TVmazeID: 3}, metadata: api.ExternalMetadata{TVmaze: &api.TVmazeMetadata{TVmazeID: 3, Name: "Example Series", Poster: "https://img.example/poster.jpg"}}, fail: true}, - {name: "ar tv missing", tracker: "AR", category: "tv", fail: true}, - {name: "spd tmdb", tracker: "SPD", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}}, - {name: "spd imdb", tracker: "SPD", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}}, - {name: "spd id only", tracker: "SPD", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, - {name: "thr imdb", tracker: "THR", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}}, - {name: "tvc tmdb", tracker: "TVC", category: "tv", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}}}, - {name: "tl imdb", tracker: "TL", category: "tv", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}}}, - {name: "bjs tmdb", tracker: "BJS", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, metadata: api.ExternalMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}}, - {name: "bjs imdb rejected", tracker: "BJS", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}, metadata: api.ExternalMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, fail: true}, - {name: "bjs tmdb id only", tracker: "BJS", category: "movie", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, - {name: "z movie imdb", tracker: "AZ", category: "movie", ids: api.ExternalIDs{IMDBID: 1234567}}, - {name: "z movie tvdb rejected", tracker: "CZ", category: "movie", ids: api.ExternalIDs{TVDBID: 2}, fail: true}, - {name: "z tv tvdb", tracker: "PHD", category: "tv", ids: api.ExternalIDs{TVDBID: 2}}, - {name: "czteam imdb", tracker: "CZT", ids: api.ExternalIDs{IMDBID: 1234567}}, - {name: "czteam tmdb rejected", tracker: "CZT", ids: api.ExternalIDs{TMDBID: 1}, fail: true}, + { + name: "unit3d tmdb", + tracker: "AITHER", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + }, + { + name: "unit3d id only", + tracker: "AITHER", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, + { + name: "unit3d missing", + tracker: "AITHER", + fail: true, + }, + { + name: "ptp imdb", + tracker: "PTP", + ids: api.ExternalIdentity{IMDBID: 1234567}, + }, + { + name: "ptp warning", + tracker: "PTP", + warning: true, + }, + { + name: "hdb movie imdb", + tracker: "HDB", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + }, + { + name: "hdb movie tvdb rejected", + tracker: "HDB", + category: "movie", + ids: api.ExternalIdentity{TVDBID: 2}, + fail: true, + }, + { + name: "hdb tv imdb", + tracker: "HDB", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + }, + { + name: "hdb tv tvdb", + tracker: "HDB", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + }, + { + name: "hdb tv missing", + tracker: "HDB", + category: "tv", + fail: true, + }, + { + name: "nbl tvmaze", + tracker: "NBL", + category: "tv", + ids: api.ExternalIdentity{TVmazeID: 3}, + metadata: api.SourceScopedMetadata{TVmaze: &api.TVmazeMetadata{TVmazeID: 3, Name: "Example Series"}}, + }, + { + name: "nbl id only", + tracker: "NBL", + category: "tv", + ids: api.ExternalIdentity{TVmazeID: 3}, + fail: true, + }, + { + name: "nbl wrong provider", + tracker: "NBL", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + fail: true, + }, + { + name: "ant tmdb", + tracker: "ANT", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + }, + { + name: "ant imdb rejected", + tracker: "ANT", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + fail: true, + }, + { + name: "bhd imdb", + tracker: "BHD", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + }, + { + name: "bhd imdb id only", + tracker: "BHD", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + fail: true, + }, + { + name: "bhd tmdb rejected", + tracker: "BHD", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + fail: true, + }, + { + name: "mtv movie imdb", + tracker: "MTV", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + }, + { + name: "mtv movie tmdb", + tracker: "MTV", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + }, + { + name: "mtv movie id only", + tracker: "MTV", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, + { + name: "mtv movie tvdb", + tracker: "MTV", + category: "movie", + ids: api.ExternalIdentity{TVDBID: 2}, + metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Release"}}, + }, + { + name: "mtv tv tmdb", + tracker: "MTV", + category: "tv", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}}, + }, + { + name: "mtv tv imdb", + tracker: "MTV", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}}, + }, + { + name: "mtv tv tvdb", + tracker: "MTV", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series"}}, + }, + { + name: "mtv tv mismatched tvdb metadata", + tracker: "MTV", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{TVDBID: 3, Name: "Example Series"}}, + fail: true, + }, + { + name: "btn imdb", + tracker: "BTN", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}}, + }, + { + name: "btn tvdb", + tracker: "BTN", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series"}}, + }, + { + name: "btn id only", + tracker: "BTN", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + fail: true, + }, + { + name: "btn tmdb rejected", + tracker: "BTN", + category: "tv", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, + { + name: "ar movie imdb", + tracker: "AR", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{ + IMDBID: 1234567, + Title: "Example Release", + Cover: "https://img.example/poster.jpg", + }}, + }, + { + name: "ar movie tmdb", + tracker: "AR", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{ + TMDBID: 1, + Title: "Example Release", + Poster: "https://img.example/poster.jpg", + }}, + }, + { + name: "ar movie missing poster", + tracker: "AR", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + fail: true, + }, + { + name: "ar movie tvdb rejected", + tracker: "AR", + category: "movie", + ids: api.ExternalIdentity{TVDBID: 2}, + fail: true, + }, + { + name: "ar tv imdb", + tracker: "AR", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{ + IMDBID: 1234567, + Title: "Example Series", + Cover: "https://img.example/poster.jpg", + }}, + }, + { + name: "ar tv tmdb", + tracker: "AR", + category: "tv", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{ + TMDBID: 1, + Title: "Example Series", + Poster: "https://img.example/poster.jpg", + }}, + }, + { + name: "ar tv tvdb", + tracker: "AR", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{ + TVDBID: 2, + Name: "Example Series", + Poster: "https://img.example/poster.jpg", + }}, + }, + { + name: "ar tv tvmaze rejected", + tracker: "AR", + category: "tv", + ids: api.ExternalIdentity{TVmazeID: 3}, + metadata: api.SourceScopedMetadata{TVmaze: &api.TVmazeMetadata{ + TVmazeID: 3, + Name: "Example Series", + Poster: "https://img.example/poster.jpg", + }}, + fail: true, + }, + { + name: "ar tv missing", + tracker: "AR", + category: "tv", + fail: true, + }, + { + name: "spd tmdb", + tracker: "SPD", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + }, + { + name: "spd imdb", + tracker: "SPD", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + }, + { + name: "spd id only", + tracker: "SPD", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, + { + name: "thr imdb", + tracker: "THR", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + }, + { + name: "tvc tmdb", + tracker: "TVC", + category: "tv", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Series"}}, + }, + { + name: "tl imdb", + tracker: "TL", + category: "tv", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}}, + }, + { + name: "bjs tmdb", + tracker: "BJS", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + metadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{TMDBID: 1, Title: "Example Release"}}, + }, + { + name: "bjs imdb rejected", + tracker: "BJS", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + metadata: api.SourceScopedMetadata{IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}}, + fail: true, + }, + { + name: "bjs tmdb id only", + tracker: "BJS", + category: "movie", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, + { + name: "z movie imdb", + tracker: "AZ", + category: "movie", + ids: api.ExternalIdentity{IMDBID: 1234567}, + }, + { + name: "z movie tvdb rejected", + tracker: "CZ", + category: "movie", + ids: api.ExternalIdentity{TVDBID: 2}, + fail: true, + }, + { + name: "z tv tvdb", + tracker: "PHD", + category: "tv", + ids: api.ExternalIdentity{TVDBID: 2}, + }, + { + name: "czteam imdb", + tracker: "CZT", + ids: api.ExternalIdentity{IMDBID: 1234567}, + }, + { + name: "czteam tmdb rejected", + tracker: "CZT", + ids: api.ExternalIdentity{TMDBID: 1}, + fail: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ExternalIDs: tc.ids, ExternalMetadata: tc.metadata} - meta.ExternalIDs.Category = tc.category - failures, evaluated := evaluateMetadataRequirements(tc.tracker, meta) + meta := api.RuleSubject{Identity: tc.ids, ProviderMetadata: tc.metadata} + meta.Identity.Category, _ = api.NormalizeCanonicalCategory(tc.category) + failures, evaluated := evaluateMetadataRequirementsWithRegistry(registry, tc.tracker, meta) if !evaluated { t.Fatal("expected metadata policy evaluation") } if tc.warning { - if len(failures) != 1 || failures[0].Severity != api.RuleFailureSeverityWarning { - t.Fatalf("expected one warning, got %#v", failures) + if len(failures) != 1 || failures[0].Disposition != api.RuleDispositionAdvisory { + t.Fatalf("expected one advisory, got %#v", failures) } return } @@ -99,11 +430,15 @@ func TestMetadataRequirementMatrix(t *testing.T) { func TestMetadataRequirementRejectsStaleSourceData(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - SourcePath: "current", - ExternalIDs: api.ExternalIDs{SourcePath: "stale", Category: "movie", TMDBID: 1}, + meta := api.RuleSubject{ + SourcePath: "current", + Identity: api.ExternalIdentity{ + SourcePath: "stale", + Category: "movie", + TMDBID: 1, + }, } - failures, _ := evaluateMetadataRequirements("ANT", meta) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "ANT", meta) if !api.HasBlockingRuleFailures(failures) { t.Fatalf("expected stale ID to fail, got %#v", failures) } @@ -111,77 +446,159 @@ func TestMetadataRequirementRejectsStaleSourceData(t *testing.T) { func TestMetadataRequirementRejectsMismatchedProviderSnapshot(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "movie", TMDBID: 1}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "movie", TMDBID: 1}, + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{TMDBID: 2, Title: "Example Release"}, }, } - failures, _ := evaluateMetadataRequirements("ANT", meta) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "ANT", meta) if !api.HasBlockingRuleFailures(failures) { t.Fatalf("expected mismatched TMDB snapshot to fail, got %#v", failures) } } -func TestMetadataPolicyForReturnsClonedRequirements(t *testing.T) { +func newMetadataRegistry(t *testing.T) *Registry { + t.Helper() + registry := NewRegistry() + policy := &TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDB}}}} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "ANT", + Definition: stubDefinition{name: "ANT"}, + Metadata: policy, + }); err != nil { + t.Fatalf("register ANT metadata policy: %v", err) + } + bhdPolicy := &TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldIMDB}}}} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "BHD", + Definition: stubDefinition{name: "BHD"}, + Metadata: bhdPolicy, + }); err != nil { + t.Fatalf("register BHD metadata policy: %v", err) + } + btnPolicy := &TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldIMDB, MetadataFieldTVDB}}}} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "BTN", + Definition: stubDefinition{name: "BTN"}, + Metadata: btnPolicy, + }); err != nil { + t.Fatalf("register BTN metadata policy: %v", err) + } + hdbPolicy := &TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{ + {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}}, + {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly, MetadataFieldTVDBIDOnly}}, + }} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "HDB", + Definition: stubDefinition{name: "HDB"}, + Metadata: hdbPolicy, + }); err != nil { + t.Fatalf("register HDB metadata policy: %v", err) + } + register := func(name string, policy TrackerMetadataPolicy) { + t.Helper() + if err := registry.RegisterDescriptor(Descriptor{ + Name: name, + Definition: stubDefinition{name: name}, + Metadata: &policy, + }); err != nil { + t.Fatalf("register %s metadata policy: %v", name, err) + } + } + register("AITHER", TrackerMetadataPolicy{Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB}}}}) + register("PTP", TrackerMetadataPolicy{Requirements: []MetadataRequirement{{ + Scope: MetadataScopeAny, + AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}, + Disposition: api.RuleDispositionAdvisory, + }}}) + register("NBL", TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTVmaze}}}}) + register("MTV", TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{ + Scope: MetadataScopeAny, + AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB, MetadataFieldTVDB}, + Disposition: api.RuleDispositionStrict, + }}}) + register("AR", TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{ + {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}, + {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB, MetadataFieldTVDB}}, + {Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldPoster}}, + }}) + for _, name := range []string{"SPD", "THR", "TVC", "TL"} { + register(name, TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB, MetadataFieldIMDB}}}}) + } + register("BJS", TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldTMDB}}}}) + multiID := TrackerMetadataPolicy{RequireKnownCategory: true, Requirements: []MetadataRequirement{ + {Scope: MetadataScopeMovie, AnyOf: []MetadataField{MetadataFieldTMDBIDOnly, MetadataFieldIMDBIDOnly}}, + {Scope: MetadataScopeTV, AnyOf: []MetadataField{MetadataFieldTMDBIDOnly, MetadataFieldIMDBIDOnly, MetadataFieldTVDBIDOnly}}, + }} + for _, name := range []string{"AZ", "CZ", "PHD"} { + register(name, multiID) + } + register("CZT", TrackerMetadataPolicy{Requirements: []MetadataRequirement{{Scope: MetadataScopeAny, AnyOf: []MetadataField{MetadataFieldIMDBIDOnly}}}}) + return registry +} + +func TestRegistryMetadataPolicyReturnsClonedRequirements(t *testing.T) { t.Parallel() - first, ok := MetadataPolicyFor("HDB") + registry := newMetadataRegistry(t) + first, ok := registry.LookupMetadataPolicy("MTV") if !ok { - t.Fatal("expected HDB policy") + t.Fatal("expected MTV policy") } - first.Requirements[0].AnyOf[0] = MetadataFieldTMDBIDOnly - second, _ := MetadataPolicyFor("HDB") - if second.Requirements[0].AnyOf[0] != MetadataFieldIMDBIDOnly { + first.Requirements[0].AnyOf[0] = MetadataFieldPoster + second, _ := registry.LookupMetadataPolicy("MTV") + if second.Requirements[0].AnyOf[0] != MetadataFieldTMDB { t.Fatalf("policy was mutated: %#v", second) } } -func TestMetadataPolicyLookupNormalizationAndExactFamilyMatch(t *testing.T) { +func TestRegistryMetadataPolicyLookupNormalizationAndExactMatch(t *testing.T) { t.Parallel() - if _, ok := MetadataPolicyFor(" aither "); !ok { + registry := newMetadataRegistry(t) + if _, ok := registry.LookupMetadataPolicy(" aither "); !ok { t.Fatal("expected normalized Unit3D policy lookup") } - if _, ok := MetadataPolicyFor("AITHER_EXTRA"); ok { + if _, ok := registry.LookupMetadataPolicy("AITHER_EXTRA"); ok { t.Fatal("unexpected prefix match for unknown tracker") } } func TestMetadataRequirementNeedsKnownCategory(t *testing.T) { t.Parallel() - failures, evaluated := evaluateMetadataRequirements("HDB", api.PreparedMetadata{ExternalIDs: api.ExternalIDs{IMDBID: 1234567}}) + failures, evaluated := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "HDB", api.RuleSubject{Identity: api.ExternalIdentity{IMDBID: 1234567}}) if !evaluated || len(failures) != 1 || failures[0].Rule != "require_metadata_category" || !api.HasBlockingRuleFailures(failures) { t.Fatalf("expected blocking category result, got %#v", failures) } } -func TestTVDBTitleRequirementRejectsStaleProviderMetadata(t *testing.T) { +func TestMTVMetadataRequirementRejectsStaleTVDBMetadata(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - SourcePath: "current", - ExternalIDs: api.ExternalIDs{SourcePath: "current", Category: "tv", TMDBID: 1, TVDBID: 2}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + SourcePath: "current", + Identity: api.ExternalIdentity{ + SourcePath: "current", + Category: "tv", + TVDBID: 2, + }, + ProviderMetadata: api.SourceScopedMetadata{ SourcePath: "stale", TVDB: &api.TVDBMetadata{TVDBID: 2, Name: "Example Series"}, }, } - failures, _ := evaluateMetadataRequirements("MTV", meta) - found := false - for _, failure := range failures { - if failure.Rule == "require_tvdb_title" { - found = true - break - } - } - if !found { - t.Fatalf("expected stale TVDB metadata failure, got %#v", failures) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "MTV", meta) + if len(failures) != 1 || failures[0].Rule != "require_metadata_id" || failures[0].Disposition != api.RuleDispositionStrict { + t.Fatalf("expected strict stale TVDB metadata failure, got %#v", failures) } } -func TestPTPMetadataWarningDoesNotBlock(t *testing.T) { +func TestPTPMetadataAdvisoryDoesNotBlock(t *testing.T) { t.Parallel() - failures := EvaluateRules(context.Background(), "PTP", api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "movie"}}, nil) - if len(failures) != 1 || failures[0].Severity != api.RuleFailureSeverityWarning || api.HasBlockingRuleFailures(failures) { - t.Fatalf("expected non-blocking PTP warning, got %#v", failures) + failures, err := EvaluateRulesWithRegistry(context.Background(), newMetadataRegistry(t), "PTP", api.RuleSubject{Identity: api.ExternalIdentity{Category: "movie"}}, nil) + if err != nil { + t.Fatalf("evaluate PTP rules: %v", err) + } + if len(failures) != 1 || failures[0].Disposition != api.RuleDispositionAdvisory || api.HasBlockingRuleFailures(failures) { + t.Fatalf("expected non-blocking PTP advisory, got %#v", failures) } } @@ -190,8 +607,12 @@ func TestMetadataRequirementTMDBOrIMDbTrackersRejectIDsAlone(t *testing.T) { for _, tracker := range []string{"SPD", "THR", "TVC", "TL"} { t.Run(tracker, func(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "tv", TMDBID: 1, IMDBID: 1234567}} - failures, evaluated := evaluateMetadataRequirements(tracker, meta) + meta := api.RuleSubject{Identity: api.ExternalIdentity{ + Category: "tv", + TMDBID: 1, + IMDBID: 1234567, + }} + failures, evaluated := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), tracker, meta) if !evaluated || !api.HasBlockingRuleFailures(failures) { t.Fatalf("expected IDs alone to fail for %s, got %#v", tracker, failures) } @@ -201,14 +622,18 @@ func TestMetadataRequirementTMDBOrIMDbTrackersRejectIDsAlone(t *testing.T) { func TestARMetadataPosterMayComeFromDifferentProvider(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "movie", TMDBID: 1, IMDBID: 1234567}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{ + Category: "movie", + TMDBID: 1, + IMDBID: 1234567, + }, + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{TMDBID: 1, Poster: "https://img.example/poster.jpg"}, IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}, }, } - failures, _ := evaluateMetadataRequirements("AR", meta) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "AR", meta) if api.HasBlockingRuleFailures(failures) { t.Fatalf("expected IMDb identity plus TMDB poster to pass, got %#v", failures) } @@ -216,14 +641,18 @@ func TestARMetadataPosterMayComeFromDifferentProvider(t *testing.T) { func TestARMetadataPosterRejectsMismatchedSnapshot(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "tv", IMDBID: 1234567, TVmazeID: 3}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{ + Category: "tv", + IMDBID: 1234567, + TVmazeID: 3, + }, + ProviderMetadata: api.SourceScopedMetadata{ IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Series"}, TVmaze: &api.TVmazeMetadata{TVmazeID: 4, PosterMedium: "https://img.example/poster.jpg"}, }, } - failures, _ := evaluateMetadataRequirements("AR", meta) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "AR", meta) if len(failures) != 1 || failures[0].Rule != "require_metadata_poster" || !api.HasBlockingRuleFailures(failures) { t.Fatalf("expected blocking poster failure, got %#v", failures) } @@ -231,17 +660,25 @@ func TestARMetadataPosterRejectsMismatchedSnapshot(t *testing.T) { func TestARMetadataPosterRejectsStaleSnapshots(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - SourcePath: "current", - ExternalIDs: api.ExternalIDs{SourcePath: "current", Category: "movie", IMDBID: 1234567}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + SourcePath: "current", + Identity: api.ExternalIdentity{ + SourcePath: "current", + Category: "movie", + IMDBID: 1234567, + }, + ProviderMetadata: api.SourceScopedMetadata{ SourcePath: "stale", - IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release", Cover: "https://img.example/poster.jpg"}, + IMDB: &api.IMDBMetadata{ + IMDBID: 1234567, + Title: "Example Release", + Cover: "https://img.example/poster.jpg", + }, }, } - failures, _ := evaluateMetadataRequirements("AR", meta) + failures, _ := evaluateMetadataRequirementsWithRegistry(newMetadataRegistry(t), "AR", meta) for _, failure := range failures { - if failure.Rule == "require_metadata_poster" && failure.Severity == api.RuleFailureSeverityBlocking { + if failure.Rule == "require_metadata_poster" && api.IsWaivableRuleFailure(failure) { return } } @@ -250,8 +687,13 @@ func TestARMetadataPosterRejectsStaleSnapshots(t *testing.T) { func TestOnlyNBLDeclaresTVmazeIdentityRequirement(t *testing.T) { t.Parallel() + registry := newMetadataRegistry(t) foundNBL := false - for tracker, policy := range trackerMetadataPolicies { + for _, tracker := range registry.Names() { + policy, ok := registry.LookupMetadataPolicy(tracker) + if !ok { + continue + } for _, requirement := range policy.Requirements { if !containsMetadataField(requirement.AnyOf, MetadataFieldTVmaze) { continue diff --git a/internal/trackers/modified_release.go b/internal/trackers/modified_release.go deleted file mode 100644 index 168f4b27..00000000 --- a/internal/trackers/modified_release.go +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package trackers - -import ( - "path/filepath" - "strings" - - "github.com/autobrr/upbrr/pkg/api" -) - -// isRenamedRelease reports whether the source media was renamed away from its -// original scene/P2P release name. Trackers reject such modified releases -// ([Modified Release] Renamed), so detecting it lets us skip the upload before it -// is rejected. Two independent, deliberately high-signal cases are checked: -// -// - *arr id tokens: Radarr/Sonarr inject "{tmdb-…}", "{imdb-…}", "{tvdb-…}", -// jellyfin "[tmdb" style, or bracket/paren variants into renamed names -// (e.g. "Example Release (2026) {imdb-tt1234567}"). These never occur in an -// original scene/P2P name, so their presence alone marks a rename. This is -// independent of the release group, which an *arr rename often strips. -// -// - whitespace renames: a grouped release (a trailing "-GROUP" tag, which -// scene/P2P releases always dot-delimit) whose on-disk name has had its dots -// replaced with spaces — e.g. a library manager rewriting -// "Example.Release.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP" to spaces. -// This path is deliberately conservative: it only fires on whitespace -// (underscore/other separator renames are out of scope), requires the parsed -// group to be the actual trailing "-GROUP" suffix so a mis-parsed token (e.g. -// an id) cannot trigger it, and skips names with parentheses/brackets/braces -// (human/library naming markers) that lack an *arr id token. -// -// Personal releases and disc-based sources are excluded from all rename checks. -// Groups in standardRenameCheckSkippedGroups skip only the heuristic checks; -// SceneRenamed remains authoritative for those groups. -// -// Both the source path (folder) and the primary video file are checked, since the -// tracker inspects the file (MediaInfo "Complete name") and the in-torrent names. -func isRenamedRelease(meta api.PreparedMetadata) (bool, string) { - if meta.PersonalRelease { - return false, "" - } - if strings.TrimSpace(meta.DiscType) != "" { - return false, "" - } - - // srrdb scene detection (the imdb: fallback) authoritatively compares the - // on-disk basename to the canonical scene media filename and sets this when - // they differ. It is the strongest signal (a database match, not a heuristic), - // so it is checked first and independent of the release group an *arr rename - // may strip. - if meta.SceneRenamed { - reason := strings.TrimSpace(meta.SceneRenamedReason) - if reason == "" { - reason = modifiedReleaseReason - } - return true, reason - } - - group := strings.TrimSpace(meta.Release.Group) - if skipsStandardRenameCheck(group) { - return false, "" - } - - names := candidateReleaseNames(meta) - - // *arr id tokens mark a rename on their own, independent of the release group - // (which an *arr rename often strips), so check them before the group gate. - for _, name := range names { - if arrRenameToken(name) != "" { - return true, modifiedReleaseReason - } - } - - if group == "" { - return false, "" - } - for _, name := range names { - if isRenamedReleaseName(name, group) { - return true, modifiedReleaseReason - } - } - return false, "" -} - -// modifiedReleaseReason is deliberately generic: it discloses neither the -// on-disk name nor which signal fired, so a modified release cannot be trivially -// resolved by renaming the file back. The source should be investigated (file -// hash, provenance) rather than papered over with a rename. -const modifiedReleaseReason = "source appears renamed or modified from its original release name; verify the file hash and source provenance" - -// standardRenameCheckSkippedGroups contains release groups that bypass only the -// standard heuristic rename checks. SRRDB scene rename matches still apply. -var standardRenameCheckSkippedGroups = map[string]struct{}{ - "HIDT": {}, - "SPHD": {}, -} - -// skipsStandardRenameCheck reports whether group is configured to bypass the -// standard non-scene rename heuristics. -func skipsStandardRenameCheck(group string) bool { - _, ok := standardRenameCheckSkippedGroups[strings.ToUpper(strings.TrimSpace(group))] - return ok -} - -// arrReleaseIDTokens are the Radarr/Sonarr id-injection tokens that appear in -// renamed filenames (e.g. "Example Release (2026) {imdb-tt1234567}") and never -// in an original scene/P2P release name. -var arrReleaseIDTokens = []string{ - "{tmdb-", "{imdb-", "{tvdb-", - "[tmdb", "(tmdb", - "[imdb", "(imdb", - "[tvdb", "(tvdb", -} - -// arrRenameToken returns the first *arr id-injection token present in name -// (case-insensitive), or "" when none is found. -func arrRenameToken(name string) string { - lower := strings.ToLower(name) - for _, token := range arrReleaseIDTokens { - if strings.Contains(lower, token) { - return token - } - } - return "" -} - -// mediaFileExtensions are the on-disk media file extensions whose suffix is -// stripped from a candidate release name. A source path is frequently a -// directory whose dotted name legitimately ends in a token like "H.265-GROUP", -// so the extension must only be removed for actual media files — otherwise -// filepath.Ext would strip that trailing token and hide the "-GROUP" tag. -var mediaFileExtensions = map[string]struct{}{ - ".mkv": {}, ".mp4": {}, ".avi": {}, ".ts": {}, ".m2ts": {}, ".m4v": {}, - ".mov": {}, ".wmv": {}, ".mpg": {}, ".mpeg": {}, ".vob": {}, ".iso": {}, -} - -// candidateReleaseNames returns the on-disk base names that should carry the -// release name (the source path and the primary video file). A recognized media -// file extension is stripped; a directory basename is kept whole so dotted -// tokens in a renamed folder name are preserved. -func candidateReleaseNames(meta api.PreparedMetadata) []string { - names := make([]string, 0, 2) - seen := make(map[string]struct{}, 2) - for _, candidate := range []string{meta.SourcePath, meta.VideoPath} { - base := strings.TrimSpace(filepath.Base(strings.TrimSpace(candidate))) - if base == "" || base == "." || base == string(filepath.Separator) { - continue - } - if ext := filepath.Ext(base); ext != "" { - if _, ok := mediaFileExtensions[strings.ToLower(ext)]; ok { - base = strings.TrimSuffix(base, ext) - } - } - if _, ok := seen[base]; ok { - continue - } - seen[base] = struct{}{} - names = append(names, base) - } - return names -} - -// isRenamedReleaseName reports whether a single base name looks like a -// space-renamed copy of a "-group" scene/P2P release. -func isRenamedReleaseName(name, group string) bool { - if name == "" { - return false - } - if !strings.ContainsAny(name, " \t") { - return false - } - // Parentheses/brackets/braces indicate human/library naming (Plex/Radarr/ - // Jellyfin), never a scene/P2P release name, so do not treat them as renames. - if strings.ContainsAny(name, "()[]{}") { - return false - } - // Require the parsed group to be the actual trailing "-GROUP" tag so a token - // the parser mistook for a group (e.g. an id) cannot trigger a false positive. - return strings.HasSuffix(strings.ToUpper(name), "-"+strings.ToUpper(group)) -} diff --git a/internal/trackers/modified_release_test.go b/internal/trackers/modified_release_test.go deleted file mode 100644 index 632ebec4..00000000 --- a/internal/trackers/modified_release_test.go +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package trackers - -import ( - "testing" - - "github.com/autobrr/upbrr/pkg/api" -) - -func TestIsRenamedRelease(t *testing.T) { - t.Parallel() - - grouped := func(sourcePath string) api.PreparedMetadata { - return api.PreparedMetadata{ - SourcePath: sourcePath, - Release: api.ReleaseInfo{Group: "GRP"}, - } - } - - cases := []struct { - name string - meta api.PreparedMetadata - want bool - }{ - { - name: "clean dotted folder with group", - meta: grouped("/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP"), - want: false, - }, - { - name: "renamed spaced folder with group", - meta: grouped("/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP"), - want: true, - }, - { - name: "renamed spaced single file with group", - meta: grouped("/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP.mkv"), - want: true, - }, - { - name: "spaced name without group tag is not flagged", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Some Home Video 2024"}, - want: false, - }, - { - name: "spaced name whose group is not the trailing tag is not flagged", - // Guards against a parser mis-extracting a token as the group. - meta: grouped("/data/movies/Some Renamed Movie 2024 1080p WEB-DL"), - want: false, - }, - { - name: "radarr-renamed folder with imdb token is flagged", - // *arr injects {imdb-…} when it renames; this is a modified release the - // tracker rejects, regardless of how rls parses the group. - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Movie (2026) {imdb-tt1234567}", - Release: api.ReleaseInfo{Group: "tt1234567"}, - }, - want: true, - }, - { - name: "sonarr-renamed file with tmdb token is flagged", - meta: api.PreparedMetadata{ - SourcePath: "/data/tv/Example Show (2026) {tmdb-12345}/Example Show S01E01 {tmdb-12345}.mkv", - VideoPath: "/data/tv/Example Show (2026) {tmdb-12345}/Example Show S01E01 {tmdb-12345}.mkv", - }, - want: true, - }, - { - name: "tvdb token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/tv/Show (2020) {tvdb-360893}"}, - want: true, - }, - { - name: "arr token match is case-insensitive", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) {IMDb-tt1234567}"}, - want: true, - }, - { - name: "arr token is flagged even when the group tag was stripped", - // *arr renames frequently drop the trailing -GROUP, so the token check - // must fire without a parsed group. - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) {tmdb-12345}"}, - want: true, - }, - { - name: "tmdb bracket token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) [tmdb-12345]"}, - want: true, - }, - { - name: "tmdb paren token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) (tmdb-12345)"}, - want: true, - }, - { - name: "imdb bracket token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) [imdb-tt1234567]"}, - want: true, - }, - { - name: "imdb paren token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/movies/Example Movie (2026) (imdb-tt1234567)"}, - want: true, - }, - { - name: "tvdb bracket token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/tv/Example Show (2026) [tvdb-12345]"}, - want: true, - }, - { - name: "tvdb paren token is flagged", - meta: api.PreparedMetadata{SourcePath: "/data/tv/Example Show (2026) (tvdb-12345)"}, - want: true, - }, - { - name: "standard rename check skipped for configured group", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Release 2026 2160p MA WEB-DL DDP5 1 HDR H 265-HiDt", - Release: api.ReleaseInfo{Group: "HiDt"}, - }, - want: false, - }, - { - name: "standard rename check skipped for SPHD", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Release 2026 2160p MA WEB-DL DDP5 1 HDR H 265-SPHD", - Release: api.ReleaseInfo{Group: "SPHD"}, - }, - want: false, - }, - { - name: "standard arr-token rename check skipped for configured group", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Release (2026) {imdb-tt1234567}-HiDt", - Release: api.ReleaseInfo{Group: "HiDt"}, - }, - want: false, - }, - { - name: "standard arr-token rename check skipped for SPHD", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Release (2026) {imdb-tt1234567}-SPHD", - Release: api.ReleaseInfo{Group: "SPHD"}, - }, - want: false, - }, - { - name: "arr-renamed personal release is exempt", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Movie (2026) {imdb-tt1234567}", - PersonalRelease: true, - }, - want: false, - }, - { - name: "arr-renamed disc source is exempt", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example Movie (2026) {imdb-tt1234567}", - DiscType: "BDMV", - }, - want: false, - }, - { - name: "edition-only brace token without an id token is not an arr rename", - // Only the {tmdb-/imdb-/tvdb-} id tokens mark an *arr rename; other - // braces (e.g. {edition-…}) must not trigger the token signal, and the - // spaces heuristic still skips bracketed names. - meta: grouped("/data/movies/Example Movie 2026 2160p WEB-DL {edition-Directors Cut}-GRP"), - want: false, - }, - { - name: "parenthesized spaced library name without an id token is not flagged", - // Guards that the bracket marker still suppresses the whitespace - // heuristic when no *arr id token is present. - meta: grouped("/data/movies/Example Movie (2026) 2160p WEB-DL-GRP"), - want: false, - }, - { - name: "personal release is exempt", - meta: func() api.PreparedMetadata { - m := grouped("/data/movies/Example Movie 2026 2160p WEB-DL-GRP") - m.PersonalRelease = true - return m - }(), - want: false, - }, - { - name: "disc source is exempt", - meta: func() api.PreparedMetadata { - m := grouped("/data/movies/Example Movie 2026 2160p BluRay-GRP") - m.DiscType = "BDMV" - return m - }(), - want: false, - }, - { - name: "clean folder containing a spaced video file is flagged", - // Finding: the tracker inspects the file, so a spaced file inside a clean - // dotted folder must still be detected. - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP", - VideoPath: "/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP.mkv", - Release: api.ReleaseInfo{Group: "GRP"}, - }, - want: true, - }, - { - name: "falls back to video path when source path is empty", - meta: api.PreparedMetadata{ - SourcePath: "", - VideoPath: "/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP.mkv", - Release: api.ReleaseInfo{Group: "GRP"}, - }, - want: true, - }, - { - name: "spaced folder retaining dotted tokens is still flagged", - // A library rename that converts most separators to spaces but leaves - // dotted tokens (DDP5.1, H.265) must not have its trailing "-GROUP" tag - // stripped by extension handling on the directory basename (filepath.Ext - // would otherwise treat ".265-GRP" as an extension). - meta: grouped("/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5.1 HDR H.265-GRP"), - want: true, - }, - { - name: "srrdb rename signal flags a clean-looking name with no group", - // The imdb: fallback authoritatively confirmed a rename that the - // heuristics above cannot see (no group tag, no *arr token, no spaces). - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example.Movie.2026.1080p.BluRay.x264", - SceneRenamed: true, - SceneRenamedReason: "source appears renamed or modified from its original release name; verify the file hash and source provenance", - }, - want: true, - }, - { - name: "srrdb rename signal still flags configured standard-skip group", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example.Release.2026.1080p.BluRay.x264-HiDt", - Release: api.ReleaseInfo{Group: "HiDt"}, - SceneRenamed: true, - }, - want: true, - }, - { - name: "srrdb rename signal is exempt for personal release", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example.Movie.2026.1080p.BluRay.x264", - PersonalRelease: true, - SceneRenamed: true, - }, - want: false, - }, - { - name: "srrdb rename signal is exempt for disc source", - meta: api.PreparedMetadata{ - SourcePath: "/data/movies/Example.Movie.2026.1080p.BluRay.x264", - DiscType: "BDMV", - SceneRenamed: true, - }, - want: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, reason := isRenamedRelease(tc.meta) - if got != tc.want { - t.Fatalf("isRenamedRelease = %v (%q), want %v", got, reason, tc.want) - } - if got && reason == "" { - t.Fatal("expected a non-empty reason when renamed") - } - }) - } -} diff --git a/internal/trackers/orchestration_locality_test.go b/internal/trackers/orchestration_locality_test.go new file mode 100644 index 00000000..52887464 --- /dev/null +++ b/internal/trackers/orchestration_locality_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestGenericOrchestrationHasNoTrackerNameDispatch(t *testing.T) { + t.Parallel() + + trackerName := regexp.MustCompile(`"(?:NBL|BTN|ANT|RTF)"`) + for _, dir := range []string{".", filepath.Join("..", "core")} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read orchestration directory %s: %v", dir, err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "e2e_enabled.go" { + continue + } + path := filepath.Join(dir, name) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read orchestration source %s: %v", path, err) + } + if match := trackerName.Find(content); match != nil { + t.Errorf("generic orchestration %s contains tracker-name dispatch %s; move behavior into the tracker profile or adapter", path, match) + } + } + } +} diff --git a/internal/trackers/persistence.go b/internal/trackers/persistence.go new file mode 100644 index 00000000..e71e8055 --- /dev/null +++ b/internal/trackers/persistence.go @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + + "github.com/autobrr/upbrr/pkg/api" +) + +// UploadPersistence exposes only persisted state used by tracker preparation, +// description assets, image-host resolution, and upload-ledger finalization. +// Implementations preserve canonical query ordering and atomic slot updates. +type UploadPersistence interface { + GetDescriptionOverride(context.Context, string, string) (api.DescriptionOverride, error) + ListDescriptionOverridesByPath(context.Context, string) ([]api.DescriptionOverride, error) + ListFinalSelections(context.Context, string) ([]api.ScreenshotFinalSelection, error) + ListTrackerMetadataByPath(context.Context, string) ([]api.TrackerMetadata, error) + ListUploadedImagesByPath(context.Context, string) ([]api.UploadedImageLink, error) + ListScreenshotSlotsByPath(context.Context, string) ([]api.ScreenshotSlot, error) + ReplaceScreenshotSlots(context.Context, string, []api.ScreenshotSlot) error + UpsertScreenshotSlotVariants(context.Context, string, []api.ScreenshotSlotVariant) error + DeleteUploadedImage(context.Context, string, string, string) error + CreateUploadRecord(context.Context, api.UploadRecord) error + UpdateLatestUploadRecordStatus(context.Context, string, string, string) error +} diff --git a/internal/trackers/plan.go b/internal/trackers/plan.go new file mode 100644 index 00000000..235b1211 --- /dev/null +++ b/internal/trackers/plan.go @@ -0,0 +1,337 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "errors" + "fmt" + "maps" + "strings" + "sync" + + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/pkg/api" +) + +// PreparationIntent selects how deeply an adapter prepares a tracker operation. +type PreparationIntent string + +const ( + // PreparationIntentDescriptionPreview resolves description content without upload artifacts or mutation. + PreparationIntentDescriptionPreview PreparationIntent = "description_preview" + // PreparationIntentDryRun resolves upload artifacts and payload preview without tracker mutation. + PreparationIntentDryRun PreparationIntent = "dry_run" + // PreparationIntentUploadReview resolves the upload payload needed for + // authorization without explicit dry-run diagnostic artifacts. + PreparationIntentUploadReview PreparationIntent = "upload_review" + // PreparationIntentUpload prepares a single-use plan that may submit to the tracker. + PreparationIntentUpload PreparationIntent = "upload" +) + +var ( + // ErrPlanNotSubmittable indicates that a plan has no upload action. + ErrPlanNotSubmittable = errors.New("tracker plan is not submittable") + // ErrPlanAlreadyUsed indicates that an upload plan already authorized one submission attempt. + ErrPlanAlreadyUsed = errors.New("tracker plan already submitted") + // ErrPlanReleased indicates that plan resources were released before submission. + ErrPlanReleased = errors.New("tracker plan already released") +) + +// PreparationFailure is a tracker-local, presentation-safe preparation failure. +// +//nolint:errname // Failure is the accepted domain outcome term used by the tracker-plan contract. +type PreparationFailure struct { + tracker string + code string + message string + cause error +} + +// NewPreparationFailure constructs a safe tracker-local failure. +func NewPreparationFailure(tracker string, code string, message string, cause error) *PreparationFailure { + trimmedMessage := strings.TrimSpace(redaction.RedactValue(message, nil)) + if trimmedMessage == "" { + trimmedMessage = "tracker preparation failed" + } + return &PreparationFailure{ + tracker: strings.ToUpper(strings.TrimSpace(tracker)), + code: strings.TrimSpace(code), + message: trimmedMessage, + cause: cause, + } +} + +// Error returns sanitized tracker-scoped failure text. +func (f *PreparationFailure) Error() string { + if f == nil { + return "tracker preparation failed" + } + if f.tracker == "" { + return f.message + } + return fmt.Sprintf("trackers: %s: %s", f.tracker, f.message) +} + +// Unwrap exposes the diagnostic preparation cause. +func (f *PreparationFailure) Unwrap() error { + if f == nil { + return nil + } + return f.cause +} + +// Tracker returns the normalized tracker identifier attributed to the failure. +func (f *PreparationFailure) Tracker() string { + if f == nil { + return "" + } + return f.tracker +} + +// Code returns the stable preparation failure class. +func (f *PreparationFailure) Code() string { + if f == nil { + return "" + } + return f.code +} + +// Message returns sanitized operator-facing failure detail. +func (f *PreparationFailure) Message() string { + if f == nil { + return "" + } + return f.message +} + +type planState struct { + mu sync.Mutex + used bool + released bool + submit func(context.Context) (api.UploadSummary, error) + release func() error +} + +// TrackerPlan is an immutable operation-scoped adapter plan. Its private state +// authorizes at most one submission and releases owned resources exactly once. +type TrackerPlan struct { + tracker string + intent PreparationIntent + description DescriptionResult + dryRun api.TrackerDryRunEntry + state *planState +} + +// PreparedOperation contains one immutable payload preview and the behavior +// captured from the same preparation pass. +type PreparedOperation struct { + preview api.TrackerDryRunEntry + submit func(context.Context) (api.UploadSummary, error) + release func() error +} + +// UploadPreparer builds canonical upload state once for the requested intent. +type UploadPreparer func(context.Context, PreparationInput) (PreparedOperation, error) + +// NewPreparedOperation captures an upload preview and its optional submission +// and cleanup behavior. A nil submission creates a preview-only operation. +func NewPreparedOperation( + preview api.TrackerDryRunEntry, + submit func(context.Context) (api.UploadSummary, error), + release func() error, +) PreparedOperation { + return PreparedOperation{ + preview: cloneTrackerDryRunEntry(preview), + submit: submit, + release: release, + } +} + +// NewDescriptionPlan constructs a shallow, non-submittable description plan. +func NewDescriptionPlan(tracker string, result DescriptionResult) TrackerPlan { + return TrackerPlan{ + tracker: strings.ToUpper(strings.TrimSpace(tracker)), + intent: PreparationIntentDescriptionPreview, + description: result, + state: &planState{}, + } +} + +// NewDryRunPlan constructs a non-submittable dry-run plan. +func NewDryRunPlan(tracker string, preview api.TrackerDryRunEntry, release func() error) TrackerPlan { + return newPreviewPlan(tracker, PreparationIntentDryRun, preview, release) +} + +func newPreviewPlan(tracker string, intent PreparationIntent, preview api.TrackerDryRunEntry, release func() error) TrackerPlan { + return TrackerPlan{ + tracker: strings.ToUpper(strings.TrimSpace(tracker)), + intent: intent, + dryRun: cloneTrackerDryRunEntry(preview), + state: &planState{release: release}, + } +} + +// NewUploadPlan constructs a single-use upload plan from already-prepared payload state. +func NewUploadPlan( + tracker string, + preview api.TrackerDryRunEntry, + submit func(context.Context) (api.UploadSummary, error), + release func() error, +) TrackerPlan { + return TrackerPlan{ + tracker: strings.ToUpper(strings.TrimSpace(tracker)), + intent: PreparationIntentUpload, + dryRun: cloneTrackerDryRunEntry(preview), + state: &planState{submit: submit, release: release}, + } +} + +// PrepareAdapter dispatches preparation intent through one tracker-owned upload +// preparer so preview and submission share one canonical state build. +func PrepareAdapter( + ctx context.Context, + input PreparationInput, + description func(context.Context, PreparationInput) (DescriptionResult, error), + prepareUpload UploadPreparer, +) (TrackerPlan, *PreparationFailure) { + input = clonePreparationInput(input) + if err := ctx.Err(); err != nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "canceled", "preparation canceled", err) + } + switch input.Intent { + case PreparationIntentDescriptionPreview: + if description == nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "capability", "tracker does not support shared description preparation", nil) + } + result, err := description(ctx, input) + if err != nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "description", err.Error(), err) + } + return NewDescriptionPlan(input.Tracker, result), nil + case PreparationIntentDryRun: + operation, err := prepareUpload(ctx, input) + if err != nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "dry_run", err.Error(), err) + } + return newPreviewPlan(input.Tracker, input.Intent, operation.preview, operation.release), nil + case PreparationIntentUploadReview: + operation, err := prepareUpload(ctx, input) + if err != nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "upload_review", err.Error(), err) + } + return newPreviewPlan(input.Tracker, input.Intent, operation.preview, operation.release), nil + case PreparationIntentUpload: + operation, err := prepareUpload(ctx, input) + if err != nil { + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "upload", err.Error(), err) + } + if operation.submit == nil { + cleanupErr := error(nil) + if operation.release != nil { + cleanupErr = operation.release() + } + cause := errors.New("prepared operation has no submission") + if cleanupErr != nil { + cause = errors.Join(cause, cleanupErr) + } + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "upload", "tracker upload preparation is not submittable", cause) + } + return NewUploadPlan(input.Tracker, operation.preview, operation.submit, operation.release), nil + default: + return TrackerPlan{}, NewPreparationFailure(input.Tracker, "intent", "unsupported preparation intent", nil) + } +} + +func clonePreparationInput(input PreparationInput) PreparationInput { + if input.Assets != nil { + assets, _ := PreparedDescriptionAssets(input.Assets) + input.Assets = &assets + } + return input +} + +// Tracker returns the normalized tracker identifier bound to the plan. +func (p TrackerPlan) Tracker() string { return p.tracker } + +// Intent returns the preparation depth used to create the plan. +func (p TrackerPlan) Intent() PreparationIntent { return p.intent } + +// Description returns the prepared description result. +func (p TrackerPlan) Description() DescriptionResult { return p.description } + +// DryRun returns a defensive copy of the prepared payload preview. +func (p TrackerPlan) DryRun() api.TrackerDryRunEntry { return cloneTrackerDryRunEntry(p.dryRun) } + +// Submit invokes the upload action at most once. The first accepted call consumes +// the submission attempt before invoking the callback, even when it returns an +// error. +func (p TrackerPlan) Submit(ctx context.Context) (api.UploadSummary, error) { + if p.state == nil || p.intent != PreparationIntentUpload { + return api.UploadSummary{}, ErrPlanNotSubmittable + } + p.state.mu.Lock() + switch { + case p.state.released: + p.state.mu.Unlock() + return api.UploadSummary{}, ErrPlanReleased + case p.state.used: + p.state.mu.Unlock() + return api.UploadSummary{}, ErrPlanAlreadyUsed + case p.state.submit == nil: + p.state.mu.Unlock() + return api.UploadSummary{}, ErrPlanNotSubmittable + } + p.state.used = true + submit := p.state.submit + p.state.mu.Unlock() + return submit(ctx) +} + +// Release invokes plan cleanup at most once and prevents later submission. +// Repeated calls are no-ops and return nil. +func (p TrackerPlan) Release() error { + if p.state == nil { + return nil + } + p.state.mu.Lock() + if p.state.released { + p.state.mu.Unlock() + return nil + } + p.state.released = true + release := p.state.release + p.state.mu.Unlock() + if release == nil { + return nil + } + return release() +} + +func cloneTrackerDryRunEntry(entry api.TrackerDryRunEntry) api.TrackerDryRunEntry { + entry.Payload = maps.Clone(entry.Payload) + entry.Files = append([]api.TrackerDryRunFile(nil), entry.Files...) + entry.DebugSections = append([]api.TrackerDryRunDebugSection(nil), entry.DebugSections...) + entry.Diagnostics.RuleDecisions = append([]api.RuleDecision(nil), entry.Diagnostics.RuleDecisions...) + entry.Diagnostics.LiveEligibilityReasons = append([]api.TrackerEligibilityReason(nil), entry.Diagnostics.LiveEligibilityReasons...) + for idx := range entry.DebugSections { + entry.DebugSections[idx].Payload = maps.Clone(entry.DebugSections[idx].Payload) + entry.DebugSections[idx].Files = append([]api.TrackerDryRunFile(nil), entry.DebugSections[idx].Files...) + } + if entry.Questionnaire != nil { + questionnaire := *entry.Questionnaire + questionnaire.Fields = append([]api.TrackerQuestionnaireField(nil), questionnaire.Fields...) + for idx := range questionnaire.Fields { + questionnaire.Fields[idx].Options = append([]string(nil), questionnaire.Fields[idx].Options...) + } + entry.Questionnaire = &questionnaire + } + entry.ImageHost.AllowedHosts = append([]string(nil), entry.ImageHost.AllowedHosts...) + entry.ImageHost.Warnings = append([]api.ImageHostWarning(nil), entry.ImageHost.Warnings...) + if entry.ContentFailure != nil { + failure := *entry.ContentFailure + entry.ContentFailure = &failure + } + return entry +} diff --git a/internal/trackers/plan_test.go b/internal/trackers/plan_test.go new file mode 100644 index 00000000..854d04eb --- /dev/null +++ b/internal/trackers/plan_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "errors" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestTrackerPlanIsImmutableSingleUseAndExactOnceRelease(t *testing.T) { + t.Parallel() + preview := api.TrackerDryRunEntry{ + Tracker: "AITHER", + Status: "ready", + Payload: map[string]string{"name": "Example.Release.2026.1080p-GRP"}, + Files: []api.TrackerDryRunFile{{ + Field: "torrent", + Path: "example.torrent", + Present: true, + }}, + } + var submits atomic.Int32 + var releases atomic.Int32 + plan := NewUploadPlan("AITHER", preview, func(context.Context) (api.UploadSummary, error) { + submits.Add(1) + return api.UploadSummary{Uploaded: 1}, nil + }, func() error { + releases.Add(1) + return nil + }) + + preview.Payload["name"] = "mutated" + preview.Files[0].Path = "mutated" + first := plan.DryRun() + if first.Payload["name"] != "Example.Release.2026.1080p-GRP" || first.Files[0].Path != "example.torrent" { + t.Fatalf("plan retained caller mutation: %#v", first) + } + first.Payload["name"] = "mutated again" + if plan.DryRun().Payload["name"] != "Example.Release.2026.1080p-GRP" { + t.Fatal("dry-run accessor exposes mutable plan state") + } + + summary, err := plan.Submit(context.Background()) + if err != nil || summary.Uploaded != 1 { + t.Fatalf("submit = %#v, %v", summary, err) + } + if _, err := plan.Submit(context.Background()); !errors.Is(err, ErrPlanAlreadyUsed) { + t.Fatalf("second submit error = %v", err) + } + if err := plan.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if err := plan.Release(); err != nil { + t.Fatalf("second release: %v", err) + } + if submits.Load() != 1 || releases.Load() != 1 { + t.Fatalf("submits=%d releases=%d", submits.Load(), releases.Load()) + } +} + +func TestTrackerPlanReleaseBeforeSubmitRejectsSubmission(t *testing.T) { + t.Parallel() + plan := NewUploadPlan("BLU", api.TrackerDryRunEntry{}, func(context.Context) (api.UploadSummary, error) { + return api.UploadSummary{Uploaded: 1}, nil + }, nil) + if err := plan.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if _, err := plan.Submit(context.Background()); !errors.Is(err, ErrPlanReleased) { + t.Fatalf("submit after release error = %v", err) + } +} + +func TestNonUploadPlansCannotSubmit(t *testing.T) { + t.Parallel() + for _, plan := range []TrackerPlan{ + NewDescriptionPlan("AITHER", DescriptionResult{Group: "unit3d", Description: "example"}), + NewDryRunPlan("AITHER", api.TrackerDryRunEntry{Tracker: "AITHER"}, nil), + } { + if _, err := plan.Submit(context.Background()); !errors.Is(err, ErrPlanNotSubmittable) { + t.Fatalf("submit error = %v", err) + } + } +} + +func TestPrepareAdapterBuildsUploadOperationOnce(t *testing.T) { + t.Parallel() + + var builds atomic.Int32 + preparedName := "" + input := PreparationInput{ + Intent: PreparationIntentUpload, + Tracker: "AITHER", + Meta: api.UploadSubject{ReleaseName: "Example.Release.2026.1080p-GRP"}, + } + plan, failure := PrepareAdapter( + context.Background(), + input, + func(context.Context, PreparationInput) (DescriptionResult, error) { + return DescriptionResult{}, nil + }, + func(_ context.Context, preparedInput PreparationInput) (PreparedOperation, error) { + builds.Add(1) + preparedName = preparedInput.Meta.ReleaseName + preview := api.TrackerDryRunEntry{Tracker: preparedInput.Tracker, Payload: map[string]string{"name": preparedName}} + return NewPreparedOperation(preview, func(context.Context) (api.UploadSummary, error) { + if preparedName != "Example.Release.2026.1080p-GRP" { + t.Fatalf("captured release name = %q", preparedName) + } + return api.UploadSummary{Uploaded: 1}, nil + }, nil), nil + }, + ) + if failure != nil { + t.Fatalf("prepare upload: %v", failure) + } + input.Meta.ReleaseName = "mutated" + if got := plan.DryRun().Payload["name"]; got != "Example.Release.2026.1080p-GRP" { + t.Fatalf("preview name = %q", got) + } + if _, err := plan.Submit(context.Background()); err != nil { + t.Fatalf("submit: %v", err) + } + if builds.Load() != 1 { + t.Fatalf("upload preparation builds = %d", builds.Load()) + } +} + +func TestPrepareAdapterKeepsPreviewIntentsNonSubmittable(t *testing.T) { + t.Parallel() + + for _, intent := range []PreparationIntent{PreparationIntentDryRun, PreparationIntentUploadReview} { + t.Run(string(intent), func(t *testing.T) { + t.Parallel() + var releases atomic.Int32 + plan, failure := PrepareAdapter( + context.Background(), + PreparationInput{Intent: intent, Tracker: "BLU"}, + func(context.Context, PreparationInput) (DescriptionResult, error) { + return DescriptionResult{}, nil + }, + func(context.Context, PreparationInput) (PreparedOperation, error) { + return NewPreparedOperation( + api.TrackerDryRunEntry{Tracker: "BLU"}, + func(context.Context) (api.UploadSummary, error) { + t.Fatal("preview intent reached submission") + return api.UploadSummary{}, nil + }, + func() error { + releases.Add(1) + return nil + }, + ), nil + }, + ) + if failure != nil { + t.Fatalf("prepare %s: %v", intent, failure) + } + if plan.Intent() != intent { + t.Fatalf("plan intent = %q, want %q", plan.Intent(), intent) + } + if _, err := plan.Submit(context.Background()); !errors.Is(err, ErrPlanNotSubmittable) { + t.Fatalf("submit error = %v", err) + } + if err := plan.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if releases.Load() != 1 { + t.Fatalf("releases = %d", releases.Load()) + } + }) + } +} + +func TestPreparationInputExcludesBroadRuntimeDependencies(t *testing.T) { + t.Parallel() + typeOf := reflect.TypeFor[PreparationInput]() + for _, forbidden := range []string{"AppConfig", "Repo", "Registry", "Images"} { + if _, ok := typeOf.FieldByName(forbidden); ok { + t.Fatalf("PreparationInput retains broad dependency field %s", forbidden) + } + } +} + +func TestPreparationFailureSanitizesMessage(t *testing.T) { + t.Parallel() + failure := NewPreparationFailure("AITHER", "auth", "request failed: https://tracker.invalid/upload?api_key=secret-value", errors.New("raw cause")) + if strings.Contains(failure.Message(), "secret-value") || strings.Contains(failure.Error(), "secret-value") { + t.Fatalf("failure exposed credential: %q", failure.Error()) + } + if !errors.Is(failure, failure.Unwrap()) { + t.Fatal("failure did not retain its private diagnostic cause") + } +} diff --git a/internal/trackers/plan_test_helpers_test.go b/internal/trackers/plan_test_helpers_test.go new file mode 100644 index 00000000..7986f7f1 --- /dev/null +++ b/internal/trackers/plan_test_helpers_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + + "github.com/autobrr/upbrr/pkg/api" +) + +type testDescriptionPreparer interface { + prepareDescription(context.Context, PreparationInput) (DescriptionResult, error) +} + +type testDryRunPreparer interface { + prepareDryRun(context.Context, PreparationInput) (api.TrackerDryRunEntry, error) +} + +type testSubmitter interface { + submit(context.Context, PreparationInput) (api.UploadSummary, error) +} + +func prepareTestDefinition(ctx context.Context, input PreparationInput, definition any) (TrackerPlan, *PreparationFailure) { + description := func(context.Context, PreparationInput) (DescriptionResult, error) { + return DescriptionResult{Group: input.Tracker}, nil + } + if builder, ok := definition.(testDescriptionPreparer); ok { + description = builder.prepareDescription + } + submit := func(context.Context, PreparationInput) (api.UploadSummary, error) { + return api.UploadSummary{}, nil + } + if uploader, ok := definition.(testSubmitter); ok { + submit = uploader.submit + } + prepareUpload := func(ctx context.Context, preparedInput PreparationInput) (PreparedOperation, error) { + preview := api.TrackerDryRunEntry{Tracker: input.Tracker, Status: "ready"} + if builder, ok := definition.(testDryRunPreparer); ok { + var err error + preview, err = builder.prepareDryRun(ctx, preparedInput) + if err != nil { + return PreparedOperation{}, err + } + } + var submitPrepared func(context.Context) (api.UploadSummary, error) + if preparedInput.Intent == PreparationIntentUpload { + submitPrepared = func(submitCtx context.Context) (api.UploadSummary, error) { + return submit(submitCtx, preparedInput) + } + } + return NewPreparedOperation(preview, submitPrepared, nil), nil + } + return PrepareAdapter(ctx, input, description, prepareUpload) +} diff --git a/internal/trackers/prepared_upload_content.go b/internal/trackers/prepared_upload_content.go new file mode 100644 index 00000000..4893b2ee --- /dev/null +++ b/internal/trackers/prepared_upload_content.go @@ -0,0 +1,134 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "errors" + "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/logging" + "github.com/autobrr/upbrr/pkg/api" +) + +type preparedUploadContentState string + +const ( + preparedUploadContentNotRequired preparedUploadContentState = "not_required" + preparedUploadContentReady preparedUploadContentState = "ready" + preparedUploadContentFailed preparedUploadContentState = "failed" +) + +// preparedUploadContent is the sole tracker-scoped result between shared +// content preparation and tracker adapter invocation. +type preparedUploadContent struct { + Mode UploadContentMode + State preparedUploadContentState + Assets *DescriptionAssets + ImageHost api.ImageHostFeedback + Failure *api.TrackerContentFailure +} + +func (s *Service) prepareUploadContent( + ctx context.Context, + tracker string, + meta api.UploadSubject, + trackerCfg config.TrackerConfig, + preloaded *preloadedDescriptionAssetData, + preflight imageHostPreflight, +) preparedUploadContent { + mode, ok := s.registry.LookupUploadContentMode(tracker) + if !ok { + return failedPreparedUploadContent(tracker, UploadContentModeDescription, errors.New("tracker upload content capability is unavailable")) + } + if mode == UploadContentModeNone { + return preparedUploadContent{Mode: mode, State: preparedUploadContentNotRequired} + } + + resolution, found := preflight[normalizeTrackerName(tracker)] + var err error + if !found { + resolution, err = ensureDescriptionImageHostWithDataAndRegistry( + ctx, + tracker, + meta, + s.cfg, + trackerCfg, + s.repo, + s.images, + logging.FromContext(ctx, s.logger), + s.registry, + preloaded, + ) + } + if err != nil { + return failedPreparedUploadContent(tracker, mode, err) + } + if resolution.blocking { + message := strings.TrimSpace(resolution.feedback.Message) + if message == "" { + message = "image-host requirements could not be met" + } + failed := failedPreparedUploadContent(tracker, mode, errors.New(message)) + failed.ImageHost = blockedPreparationImageHostFeedback(resolution.feedback) + return failed + } + + var assets DescriptionAssets + switch mode { + case UploadContentModeNone: + return preparedUploadContent{Mode: mode, State: preparedUploadContentNotRequired} + case UploadContentModeScreenshots: + assets, err = resolveScreenshotAssets(ctx, tracker, meta, s.repo, logging.FromContext(ctx, s.logger), preloaded, s.registry) + case UploadContentModeDescription: + assets, err = resolveDescriptionAssets(ctx, tracker, meta, s.repo, logging.FromContext(ctx, s.logger), preloaded, s.registry) + default: + err = errors.New("unsupported tracker upload content mode") + } + if err != nil { + return failedPreparedUploadContent(tracker, mode, err) + } + applyResolvedDescriptionScreenshots(ctx, meta, s.repo, preloaded, &assets, resolution.screenshots) + return preparedUploadContent{ + Mode: mode, + State: preparedUploadContentReady, + Assets: &assets, + ImageHost: resolution.feedback, + } +} + +func failedPreparedUploadContent(tracker string, mode UploadContentMode, err error) preparedUploadContent { + message := safeTrackerMessage(err) + return preparedUploadContent{ + Mode: mode, + State: preparedUploadContentFailed, + Failure: &api.TrackerContentFailure{ + Tracker: normalizeTrackerName(tracker), + Code: mode.FailureReasonCode(), + Message: message, + }, + } +} + +func resolveScreenshotAssets( + ctx context.Context, + tracker string, + meta api.UploadSubject, + repo UploadPersistence, + logger api.Logger, + preloaded *preloadedDescriptionAssetData, + registry *Registry, +) (DescriptionAssets, error) { + slots, screenshots, err := resolveDescriptionScreenshots(ctx, tracker, meta, repo, logger, preloaded, registry) + if err != nil { + return DescriptionAssets{}, err + } + menuImages, normalScreenshots := splitDescriptionScreenshots(ctx, meta, repo, preloaded, screenshots) + return DescriptionAssets{ + Screenshots: normalScreenshots, + MenuImages: menuImages, + Slots: slots, + }, nil +} diff --git a/internal/trackers/registry.go b/internal/trackers/registry.go index cc785106..941f043b 100644 --- a/internal/trackers/registry.go +++ b/internal/trackers/registry.go @@ -6,32 +6,504 @@ package trackers import ( "errors" "fmt" + "net/url" + "regexp" + "sort" "strings" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" ) +// Registry stores construction-time tracker definitions and typed capabilities. +// Names are normalized for case-insensitive lookup; pointer-valued capabilities +// exposed through [Registry.LookupDescriptor] remain registry-owned and must be +// treated as read-only. type Registry struct { - defs map[string]Definition + descriptors map[string]Descriptor + priority []string +} + +// SetPriorityOrder replaces the curated preference order with a lower-case, +// stable, deduplicated copy. +func (r *Registry) SetPriorityOrder(names []string) { + if r == nil { + return + } + r.priority = normalizeRegistryNames(names) } +// Priority returns curated names followed by remaining Unit3D names. +func (r *Registry) Priority() []string { + if r == nil { + return nil + } + ordered := append([]string(nil), r.priority...) + seen := make(map[string]struct{}, len(ordered)) + for _, name := range ordered { + seen[name] = struct{}{} + } + for _, name := range r.NamesByFamily(FamilyUnit3D) { + lower := strings.ToLower(name) + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + ordered = append(ordered, lower) + } + return ordered +} + +func normalizeRegistryNames(names []string) []string { + normalized := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + lower := strings.ToLower(strings.TrimSpace(name)) + if lower == "" { + continue + } + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + normalized = append(normalized, lower) + } + return normalized +} + +// NewRegistry returns an empty tracker registry. func NewRegistry() *Registry { - return &Registry{defs: make(map[string]Definition)} + return &Registry{descriptors: make(map[string]Descriptor)} } +// Register discovers the optional capabilities implemented by def and registers +// the resulting descriptor. It rejects nil, unnamed, mismatched, or duplicate definitions. func (r *Registry) Register(def Definition) error { + descriptor := Descriptor{Definition: def} + if def != nil { + descriptor.Name = def.Name() + descriptor.Family = FamilyStandalone + if provider, ok := def.(BaseURLProvider); ok { + descriptor.BaseURL = strings.TrimSpace(provider.DefaultBaseURL()) + } + if provider, ok := def.(FamilyProvider); ok { + descriptor.Family = provider.TrackerFamily() + } + if provider, ok := def.(LocalizedMetadataProvider); ok { + descriptor.MetadataLocale = strings.TrimSpace(provider.LocalizedMetadataLocale()) + } + if provider, ok := def.(DescriptionGroupProvider); ok { + descriptor.DescriptionGroup = strings.ToLower(strings.TrimSpace(provider.DescriptionGroup())) + } + if provider, ok := def.(UploadContentModeProvider); ok { + descriptor.UploadContentMode = provider.UploadContentMode() + } + descriptor.DataFactory, _ = def.(DataLookupFactory) + descriptor.ClaimFactory, _ = def.(ClaimCheckerFactory) + if provider, ok := def.(ClaimPolicyProvider); ok { + descriptor.ClaimPolicy = provider.ClaimPolicy() + } + if provider, ok := def.(RuleProvider); ok { + descriptor.Rules = provider.Rules() + } + if provider, ok := def.(DataLookupPolicyProvider); ok { + descriptor.DataPolicy = provider.DataLookupPolicy() + } + if provider, ok := def.(ArtifactPolicyProvider); ok { + descriptor.Artifact = provider.ArtifactPolicy() + } + if provider, ok := def.(BannedGroupsProvider); ok { + descriptor.BannedGroups = append([]string(nil), provider.BannedGroups()...) + } + if provider, ok := def.(BannedGroupPolicyProvider); ok { + descriptor.BannedPolicy = provider.BannedGroupPolicy() + } + if provider, ok := def.(MetadataPolicyProvider); ok { + descriptor.Metadata = provider.MetadataPolicy() + } + if provider, ok := def.(UploadArtifactPolicyProvider); ok { + descriptor.UploadArtifact = provider.UploadArtifactPolicy() + } + if provider, ok := def.(DupePolicyProvider); ok { + descriptor.DupePolicy = provider.DupePolicy() + } + if provider, ok := def.(AudioPolicyProvider); ok { + descriptor.AudioPolicy = provider.AudioPolicy() + } + if provider, ok := def.(ImageHostPolicyProvider); ok { + descriptor.ImageHost = provider.ImageHostPolicy() + } + if provider, ok := def.(TorrentIdentityPolicyProvider); ok { + descriptor.TorrentIdentity = provider.TorrentIdentityPolicy() + } else if descriptor.Family == FamilyUnit3D && descriptor.BaseURL != "" { + descriptor.TorrentIdentity = &TorrentIdentityPolicy{ + TrackerURLPatterns: []string{descriptor.BaseURL}, + CommentURLPatterns: []string{descriptor.BaseURL}, + DetailIDPattern: `/(\d+)`, + } + } + if provider, ok := def.(AuthSessionProvider); ok { + descriptor.AuthResolver = provider.AuthSessionResolver() + } + if provider, ok := def.(AuthCapabilityDescriptorProvider); ok { + descriptor.AuthCapability = provider.AuthCapabilityDescriptor() + } else if provider, ok := def.(AuthCapabilityProvider); ok { + capability := provider.AuthCapability() + descriptor.AuthCapability = &capability + } + if provider, ok := def.(AuthPolicyProvider); ok { + descriptor.AuthPolicy = provider.AuthPolicy() + } + if provider, ok := def.(AuthStateManagerProvider); ok { + descriptor.AuthStateManager = provider.AuthStateManager() + } + } + return r.RegisterDescriptor(descriptor) +} + +// LookupTorrentIdentityPolicy returns tracker-owned torrent-client identity behavior. +func (r *Registry) LookupTorrentIdentityPolicy(tracker string) (TorrentIdentityPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.TorrentIdentity == nil { + return TorrentIdentityPolicy{}, false + } + policy := *descriptor.TorrentIdentity + policy.TrackerURLPatterns = append([]string(nil), policy.TrackerURLPatterns...) + policy.CommentURLPatterns = append([]string(nil), policy.CommentURLPatterns...) + return policy, true +} + +// NeedsLocalizedMetadata reports whether any registered tracker consumes locale. +func (r *Registry) NeedsLocalizedMetadata(names []string, locale string) bool { + if r == nil { + return false + } + for _, name := range names { + descriptor, ok := r.LookupDescriptor(name) + if ok && strings.EqualFold(strings.TrimSpace(descriptor.MetadataLocale), strings.TrimSpace(locale)) { + return true + } + } + return false +} + +// LookupClaimPolicy returns tracker-owned generic claim orchestration policy. +func (r *Registry) LookupClaimPolicy(tracker string) (ClaimPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.ClaimPolicy == nil { + return ClaimPolicy{}, false + } + return *descriptor.ClaimPolicy, true +} + +// LookupAuthCapability returns tracker-owned auth support metadata. +func (r *Registry) LookupAuthCapability(tracker string) (api.TrackerAuthCapability, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.AuthCapability == nil { + return api.TrackerAuthCapability{}, false + } + capability := *descriptor.AuthCapability + capability.Notes = append([]string(nil), capability.Notes...) + return capability, true +} + +// LookupAuthSessionResolver returns tracker-owned remote auth behavior. +func (r *Registry) LookupAuthSessionResolver(tracker string) (AuthSessionResolver, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.AuthResolver, ok && descriptor.AuthResolver != nil +} + +// LookupAuthPolicy returns tracker-owned auth readiness semantics. +func (r *Registry) LookupAuthPolicy(tracker string) (AuthPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.AuthPolicy == nil { + return AuthPolicy{}, false + } + return *descriptor.AuthPolicy, true +} + +// LookupAuthStateManager returns tracker-owned persisted auth cleanup. +func (r *Registry) LookupAuthStateManager(tracker string) (AuthStateManager, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.AuthStateManager, ok && descriptor.AuthStateManager != nil +} + +// LookupImageHostPolicy returns tracker-owned accepted image hosts. +func (r *Registry) LookupImageHostPolicy(tracker string) (ImageHostPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.ImageHost == nil { + return ImageHostPolicy{}, false + } + policy := *descriptor.ImageHost + policy.AllowedHosts = append([]string(nil), policy.AllowedHosts...) + policy.OwnedHosts = append([]string(nil), policy.OwnedHosts...) + return policy, true +} + +// LookupUploadContentMode returns the tracker-owned shared content workflow. +func (r *Registry) LookupUploadContentMode(tracker string) (UploadContentMode, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.UploadContentMode, ok && descriptor.UploadContentMode.Valid() +} + +// OwnerForImageHost returns the tracker that owns a private image host. +func (r *Registry) OwnerForImageHost(host string) string { + normalized := strings.ToLower(strings.TrimSpace(host)) + if normalized == "" { + return "" + } + for _, tracker := range r.Names() { + policy, ok := r.LookupImageHostPolicy(tracker) + if !ok { + continue + } + for _, ownedHost := range policy.OwnedHosts { + if strings.EqualFold(strings.TrimSpace(ownedHost), normalized) { + return tracker + } + } + } + return "" +} + +// LookupDataPolicy returns tracker-owned lookup orchestration policy. +func (r *Registry) LookupDataPolicy(tracker string) (DataLookupPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.DataPolicy == nil { + return DataLookupPolicy{}, false + } + return *descriptor.DataPolicy, true +} + +// LookupFamily returns the registered tracker protocol family. +func (r *Registry) LookupFamily(tracker string) (Family, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.Family, ok && descriptor.Family != FamilyUnknown +} + +// NamesByFamily returns registered tracker names of family in deterministic order. +func (r *Registry) NamesByFamily(family Family) []string { + if r == nil { + return nil + } + names := make([]string, 0) + for name, descriptor := range r.descriptors { + if descriptor.Family == family { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +// LookupClaimCheckerFactory returns tracker-owned claim-check construction. +func (r *Registry) LookupClaimCheckerFactory(tracker string) (ClaimCheckerFactory, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.ClaimFactory, ok && descriptor.ClaimFactory != nil +} + +// LookupAudioPolicy returns tracker-specific multi-language constraints. +func (r *Registry) LookupAudioPolicy(tracker string) (AudioPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.AudioPolicy == nil { + return AudioPolicy{}, false + } + policy := *descriptor.AudioPolicy + policy.AllowedLanguages = append([]string(nil), policy.AllowedLanguages...) + return policy, true +} + +// LookupDupePolicy returns tracker-specific duplicate comparison semantics. +func (r *Registry) LookupDupePolicy(tracker string) (DupePolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.DupePolicy == nil { + return DupePolicy{}, false + } + return *descriptor.DupePolicy, true +} + +// LookupUploadArtifactPolicy returns tracker torrent personalization fields. +func (r *Registry) LookupUploadArtifactPolicy(tracker string) (UploadArtifactPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.UploadArtifact == nil { + return UploadArtifactPolicy{}, false + } + return *descriptor.UploadArtifact, true +} + +// LookupMetadataPolicy returns tracker-owned metadata requirements. +func (r *Registry) LookupMetadataPolicy(tracker string) (TrackerMetadataPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.Metadata == nil { + return TrackerMetadataPolicy{}, false + } + return cloneMetadataPolicy(*descriptor.Metadata), true +} + +// LookupBannedGroups returns tracker-owned static banned release groups. +func (r *Registry) LookupBannedGroups(tracker string) ([]string, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || len(descriptor.BannedGroups) == 0 { + return nil, false + } + return append([]string(nil), descriptor.BannedGroups...), true +} + +// LookupBannedGroupPolicy returns tracker-owned dynamic blacklist behavior. +func (r *Registry) LookupBannedGroupPolicy(tracker string) (BannedGroupPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.BannedPolicy == nil { + return BannedGroupPolicy{}, false + } + return *descriptor.BannedPolicy, true +} + +// LookupDataFactory returns tracker's runtime metadata lookup factory. +func (r *Registry) LookupDataFactory(tracker string) (DataLookupFactory, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.DataFactory, ok && descriptor.DataFactory != nil +} + +// DataLookupConfigured reports whether tracker-owned lookup credentials are ready. +func (r *Registry) DataLookupConfigured(tracker string, cfg config.Config) (bool, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok { + return false, false + } + provider, ok := descriptor.Definition.(DataLookupConfigProvider) + if !ok { + return false, false + } + return provider.DataLookupConfigured(cfg), true +} + +// LookupArtifactPolicy returns tracker's torrent artifact constraints. +func (r *Registry) LookupArtifactPolicy(tracker string) (ArtifactPolicy, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.Artifact == nil { + return ArtifactPolicy{}, false + } + return *descriptor.Artifact, true +} + +// RegisterDescriptor validates and normalizes one construction-time descriptor. +// It rejects duplicate names, non-HTTPS endpoints, invalid families/content +// modes, malformed ID patterns, and conflicting private image-host ownership. +func (r *Registry) RegisterDescriptor(descriptor Descriptor) error { + def := descriptor.Definition if def == nil { return errors.New("trackers: definition is nil") } - name := strings.ToUpper(strings.TrimSpace(def.Name())) + name := strings.ToUpper(strings.TrimSpace(descriptor.Name)) if name == "" { return errors.New("trackers: definition has empty name") } - if _, exists := r.defs[name]; exists { + definitionName := strings.ToUpper(strings.TrimSpace(def.Name())) + if definitionName != name { + return fmt.Errorf("trackers: descriptor name %s does not match definition name %s", name, definitionName) + } + if _, exists := r.descriptors[name]; exists { return fmt.Errorf("trackers: definition already registered: %s", name) } - r.defs[name] = def + if descriptor.AuthCapability != nil { + authName := strings.ToUpper(strings.TrimSpace(descriptor.AuthCapability.TrackerID)) + if authName != name { + return fmt.Errorf("trackers: auth capability name %s does not match definition name %s", authName, name) + } + descriptor.AuthCapability.TrackerID = name + descriptor.AuthCapability.Notes = append([]string(nil), descriptor.AuthCapability.Notes...) + } + descriptor.Name = name + if descriptor.Family == FamilyUnknown { + descriptor.Family = FamilyStandalone + if provider, ok := def.(FamilyProvider); ok { + descriptor.Family = provider.TrackerFamily() + } + } + if strings.TrimSpace(descriptor.BaseURL) == "" { + if provider, ok := def.(BaseURLProvider); ok { + descriptor.BaseURL = provider.DefaultBaseURL() + } + } + descriptor.BaseURL = strings.TrimRight(strings.TrimSpace(descriptor.BaseURL), "/") + if descriptor.BaseURL == "" { + return fmt.Errorf("trackers: definition %s has empty base URL", name) + } + endpoint, err := url.Parse(descriptor.BaseURL) + if err != nil || !strings.EqualFold(endpoint.Scheme, "https") || endpoint.Host == "" { + return fmt.Errorf("trackers: definition %s has invalid HTTPS base URL %q", name, descriptor.BaseURL) + } + if descriptor.Family != FamilyUnit3D && descriptor.Family != FamilyAZFamily && descriptor.Family != FamilyStandalone { + return fmt.Errorf("trackers: definition %s has invalid family %q", name, descriptor.Family) + } + if !descriptor.UploadContentMode.Valid() { + if provider, ok := def.(UploadContentModeProvider); ok { + descriptor.UploadContentMode = provider.UploadContentMode() + } + } + if !descriptor.UploadContentMode.Valid() { + return fmt.Errorf("trackers: definition %s has invalid upload content mode %q", name, descriptor.UploadContentMode) + } + if descriptor.TorrentIdentity != nil { + policy := *descriptor.TorrentIdentity + policy.TrackerURLPatterns = normalizePolicyPatterns(policy.TrackerURLPatterns) + policy.CommentURLPatterns = normalizePolicyPatterns(policy.CommentURLPatterns) + policy.DetailIDPattern = strings.TrimSpace(policy.DetailIDPattern) + if policy.DetailIDPattern != "" { + compiled, compileErr := regexp.Compile(policy.DetailIDPattern) + if compileErr != nil || compiled.NumSubexp() < 1 { + return fmt.Errorf("trackers: definition %s has invalid torrent ID pattern %q", name, policy.DetailIDPattern) + } + } + policy.WorkingTrackerID = strings.TrimSpace(policy.WorkingTrackerID) + descriptor.TorrentIdentity = &policy + } + if descriptor.ImageHost != nil { + policy := *descriptor.ImageHost + policy.AllowedHosts = normalizePolicyPatterns(policy.AllowedHosts) + policy.OwnedHosts = normalizePolicyPatterns(policy.OwnedHosts) + for _, ownedHost := range policy.OwnedHosts { + for registeredName, registered := range r.descriptors { + if registered.ImageHost == nil { + continue + } + for _, existingHost := range registered.ImageHost.OwnedHosts { + if strings.EqualFold(existingHost, ownedHost) { + return fmt.Errorf("trackers: image host %s is owned by both %s and %s", ownedHost, registeredName, name) + } + } + } + } + descriptor.ImageHost = &policy + } + r.descriptors[name] = descriptor return nil } +func normalizePolicyPatterns(patterns []string) []string { + normalized := make([]string, 0, len(patterns)) + seen := make(map[string]struct{}, len(patterns)) + for _, pattern := range patterns { + trimmed := strings.ToLower(strings.TrimSpace(pattern)) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + return normalized +} + +// LookupBaseURL returns tracker's registered default endpoint. +func (r *Registry) LookupBaseURL(tracker string) (string, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + return descriptor.BaseURL, ok && descriptor.BaseURL != "" +} + +// Lookup returns the definition registered for tracker using a case-insensitive name. func (r *Registry) Lookup(tracker string) (Definition, bool) { if r == nil { return nil, false @@ -40,6 +512,39 @@ func (r *Registry) Lookup(tracker string) (Definition, bool) { if key == "" { return nil, false } - def, ok := r.defs[key] - return def, ok + descriptor, ok := r.descriptors[key] + return descriptor.Definition, ok +} + +// LookupDescriptor returns a shallow registry-owned capability snapshot. Callers +// needing mutable policy data must use the typed lookup methods, which copy +// mutable slices where required. +func (r *Registry) LookupDescriptor(tracker string) (Descriptor, bool) { + if r == nil { + return Descriptor{}, false + } + descriptor, ok := r.descriptors[strings.ToUpper(strings.TrimSpace(tracker))] + return descriptor, ok +} + +// LookupRules returns tracker's registered rule capability. +func (r *Registry) LookupRules(tracker string) (RuleSet, bool) { + descriptor, ok := r.LookupDescriptor(tracker) + if !ok || descriptor.Rules == nil { + return RuleSet{}, false + } + return *descriptor.Rules, true +} + +// Names returns normalized tracker names in deterministic order. +func (r *Registry) Names() []string { + if r == nil { + return nil + } + names := make([]string, 0, len(r.descriptors)) + for name := range r.descriptors { + names = append(names, name) + } + sort.Strings(names) + return names } diff --git a/internal/trackers/registry_test.go b/internal/trackers/registry_test.go index c32b5a3d..dd7acc63 100644 --- a/internal/trackers/registry_test.go +++ b/internal/trackers/registry_test.go @@ -5,8 +5,11 @@ package trackers import ( "context" + "reflect" + "strings" "testing" + "github.com/autobrr/upbrr/internal/config" "github.com/autobrr/upbrr/pkg/api" ) @@ -14,11 +17,70 @@ type stubDefinition struct { name string } +type stubAuthDefinition struct{ stubDefinition } + +func (stubAuthDefinition) AuthSessionResolver() AuthSessionResolver { + return func(context.Context, config.TrackerConfig, string, api.TrackerAuthLoginRequest) error { return nil } +} + func (s stubDefinition) Name() string { return s.name } -func (s stubDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (stubDefinition) UploadContentMode() UploadContentMode { return UploadContentModeDescription } + +func (stubDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } + +func testTrackerFamily(name string) Family { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "AITHER", "BLU", "HHD", "LST", "OE", "RF", "RHD", "STC": + return FamilyUnit3D + default: + return FamilyStandalone + } +} + +func testImageHostPolicyForTracker(name string) *ImageHostPolicy { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "GPW": + return &ImageHostPolicy{AllowedHosts: []string{"kshare", "pixhost", "pterclub", "ilikeshots", "imgbox"}} + case "HDB": + return &ImageHostPolicy{ + AllowedHosts: []string{"hdb"}, + OwnedHosts: []string{"hdb"}, + DisableWithoutRehost: true, + } + case "LST": + return &ImageHostPolicy{ + ConditionalHost: "lostimg", + OwnedHosts: []string{"lostimg"}, + EnableWithLostimg: true, + } + case "MTV": + return &ImageHostPolicy{AllowedHosts: []string{"imgbox", "imgbb"}} + case "OE": + return &ImageHostPolicy{AllowedHosts: []string{"imgbox", "imgbb", "onlyimage", "ptscreens", "passtheimage"}} + case "PTP": + return &ImageHostPolicy{AllowedHosts: []string{"pixhost", "imgbb", "onlyimage", "ptscreens", "passtheimage"}} + case "RF": + return &ImageHostPolicy{ + ConditionalHost: "reelflix", + OwnedHosts: []string{"reelflix"}, + EnableWhenConfigured: true, + } + case "STC": + return &ImageHostPolicy{AllowedHosts: []string{"imgbox", "imgbb"}} + default: + return nil + } +} + +func (s stubDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, s) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (s stubDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{Uploaded: 1}, nil } @@ -35,6 +97,16 @@ func TestRegistryRegisterLookup(t *testing.T) { } } +func TestRegistryRegistersAuthResolver(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(stubAuthDefinition{stubDefinition{name: "AUTH"}}); err != nil { + t.Fatalf("register: %v", err) + } + if _, ok := registry.LookupAuthSessionResolver("auth"); !ok { + t.Fatal("expected auth resolver") + } +} + func TestRegistryRegisterDuplicate(t *testing.T) { registry := NewRegistry() if err := registry.Register(stubDefinition{name: "BLU"}); err != nil { @@ -44,3 +116,42 @@ func TestRegistryRegisterDuplicate(t *testing.T) { t.Fatalf("expected duplicate register error") } } + +func TestRegistryRegisterDescriptorRejectsMismatchedName(t *testing.T) { + registry := NewRegistry() + err := registry.RegisterDescriptor(Descriptor{Name: "BLU", Definition: stubDefinition{name: "AITHER"}}) + if err == nil { + t.Fatal("expected mismatched descriptor name error") + } +} + +func TestRegistryDiscoversCapabilitiesAndSortsNames(t *testing.T) { + registry := NewRegistry() + for _, definition := range []Definition{ + stubDefinition{name: "ZNTH"}, + stubDefinition{name: "BLU"}, + } { + if err := registry.Register(definition); err != nil { + t.Fatalf("register: %v", err) + } + } + if got, want := registry.Names(), []string{"BLU", "ZNTH"}; !reflect.DeepEqual(got, want) { + t.Fatalf("names = %v, want %v", got, want) + } +} + +func TestRegistryRuleCapability(t *testing.T) { + registry := NewRegistry() + rules := RuleSet{RequireMovieOnly: true} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "BLU", + Definition: stubDefinition{name: "BLU"}, + Rules: &rules, + }); err != nil { + t.Fatalf("register: %v", err) + } + got, ok := registry.LookupRules("blu") + if !ok || !got.RequireMovieOnly { + t.Fatalf("rules = %#v, ok=%t", got, ok) + } +} diff --git a/internal/trackers/resolve.go b/internal/trackers/resolve.go index 2d1b7895..8c70a73e 100644 --- a/internal/trackers/resolve.go +++ b/internal/trackers/resolve.go @@ -10,25 +10,45 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) -// ResolveTrackers returns known trackers from explicit overrides when provided, -// otherwise configured defaults, after applying removals. Returned names are -// trimmed and uppercased. -func ResolveTrackers(cfg config.Config, override []string, remove []string, logger api.Logger) []string { +// ResolveTrackersWithRegistry uses explicit trackers when supplied, otherwise +// configured defaults, then applies removals and drops unregistered names while +// preserving normalized input order. +func ResolveTrackersWithRegistry(cfg config.Config, override []string, remove []string, logger api.Logger, registry *Registry) []string { resolved := resolveTrackers(cfg, override, remove) - resolved = filterKnownTrackers(resolved, logger) + resolved = filterKnownTrackersWithRegistry(resolved, logger, registry) for i, tracker := range resolved { resolved[i] = strings.ToUpper(strings.TrimSpace(tracker)) } return resolved } -// ResolveTrackersWithDefaults returns known configured default trackers plus -// explicit overrides, after applying removals. Use it for flows where explicit -// tracker selections augment defaults instead of replacing them. Returned names -// are trimmed and uppercased. -func ResolveTrackersWithDefaults(cfg config.Config, override []string, remove []string, logger api.Logger) []string { +// ResolveExplicitTrackersWithRegistry validates an already-expanded tracker +// selection without falling back to configured defaults. An explicit empty +// selection stays empty. +func ResolveExplicitTrackersWithRegistry(override []string, logger api.Logger, registry *Registry) []string { + resolved := filterKnownTrackersWithRegistry(append([]string(nil), override...), logger, registry) + seen := make(map[string]struct{}, len(resolved)) + result := make([]string, 0, len(resolved)) + for _, tracker := range resolved { + name := strings.ToUpper(strings.TrimSpace(tracker)) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + result = append(result, name) + } + return result +} + +// ResolveTrackersWithDefaultsAndRegistry appends explicit trackers to configured +// defaults, applies removals, and drops unregistered names while preserving the +// first occurrence. +func ResolveTrackersWithDefaultsAndRegistry(cfg config.Config, override []string, remove []string, logger api.Logger, registry *Registry) []string { resolved := resolveTrackersWithDefaults(cfg, override, remove) - resolved = filterKnownTrackers(resolved, logger) + resolved = filterKnownTrackersWithRegistry(resolved, logger, registry) for i, tracker := range resolved { resolved[i] = strings.ToUpper(strings.TrimSpace(tracker)) } diff --git a/internal/trackers/rule_assessment.go b/internal/trackers/rule_assessment.go new file mode 100644 index 00000000..ac5e2f13 --- /dev/null +++ b/internal/trackers/rule_assessment.go @@ -0,0 +1,131 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "fmt" + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +// RuleAssessment is the complete live-upload decision for one tracker's rule evidence. +type RuleAssessment struct { + Failures []api.RuleFailure + Decisions []api.RuleDecision + Advisory []api.RuleFailure + Waivable []api.RuleFailure + Strict []api.RuleFailure + Authorized []api.RuleFailure + Eligible bool +} + +// AssessRuleFailures applies exact rule keys while retaining all evidence. +func AssessRuleFailures(tracker string, failures []api.RuleFailure, authorizedRules []string) (RuleAssessment, error) { + name := strings.ToUpper(strings.TrimSpace(tracker)) + known := make(map[string]api.RuleFailure, len(failures)) + assessment := RuleAssessment{ + Failures: append([]api.RuleFailure(nil), failures...), + Decisions: make([]api.RuleDecision, 0, len(failures)), + Eligible: true, + } + for idx, failure := range assessment.Failures { + key := strings.TrimSpace(failure.Rule) + if key == "" { + return RuleAssessment{}, fmt.Errorf("tracker %s contains a rule failure without a key", name) + } + failure.Rule = key + failure.Reason = strings.TrimSpace(failure.Reason) + failure.Disposition = api.NormalizeRuleDisposition(failure.Disposition) + assessment.Failures[idx] = failure + known[key] = failure + } + authorized := make(map[string]struct{}, len(authorizedRules)) + for _, raw := range authorizedRules { + key := strings.TrimSpace(raw) + failure, ok := known[key] + if !ok { + return RuleAssessment{}, fmt.Errorf("tracker %s rule authorization %q does not match a current failure", name, key) + } + if !api.IsWaivableRuleFailure(failure) { + return RuleAssessment{}, fmt.Errorf("tracker %s rule authorization %q is not waivable", name, key) + } + authorized[key] = struct{}{} + } + for _, failure := range assessment.Failures { + failure.Disposition = api.NormalizeRuleDisposition(failure.Disposition) + _, isAuthorized := authorized[failure.Rule] + decision := api.RuleDecision{ + Rule: failure.Rule, + Reason: failure.Reason, + Disposition: failure.Disposition, + Authorized: isAuthorized, + } + assessment.Decisions = append(assessment.Decisions, decision) + switch failure.Disposition { + case api.RuleDispositionAdvisory: + assessment.Advisory = append(assessment.Advisory, failure) + case api.RuleDispositionWaivable: + assessment.Waivable = append(assessment.Waivable, failure) + if isAuthorized { + assessment.Authorized = append(assessment.Authorized, failure) + } else { + assessment.Eligible = false + } + case api.RuleDispositionStrict: + assessment.Strict = append(assessment.Strict, failure) + assessment.Eligible = false + } + } + return assessment, nil +} + +// AuthorizedRulesForTracker returns exact keys supplied for tracker and rejects +// duplicate tracker authorization records. +func AuthorizedRulesForTracker(authorizations []api.RuleAuthorization, tracker string) ([]string, error) { + name := strings.ToUpper(strings.TrimSpace(tracker)) + var rules []string + found := false + for _, authorization := range authorizations { + if strings.ToUpper(strings.TrimSpace(authorization.Tracker)) != name { + continue + } + if found { + return nil, fmt.Errorf("tracker %s has duplicate rule authorization records", name) + } + found = true + rules = append([]string(nil), authorization.Rules...) + } + return rules, nil +} + +// ValidateRuleAuthorizations rejects unknown trackers, unknown keys, and any +// attempt to authorize advisory or strict failures. +func ValidateRuleAuthorizations( + selected []string, + failures map[string][]api.RuleFailure, + authorizations []api.RuleAuthorization, +) error { + selectedSet := make(map[string]struct{}, len(selected)) + for _, tracker := range selected { + if name := strings.ToUpper(strings.TrimSpace(tracker)); name != "" { + selectedSet[name] = struct{}{} + } + } + seen := make(map[string]struct{}, len(authorizations)) + for _, authorization := range authorizations { + name := strings.ToUpper(strings.TrimSpace(authorization.Tracker)) + if _, ok := selectedSet[name]; !ok { + return fmt.Errorf("rule authorization references unselected tracker %s", name) + } + if _, ok := seen[name]; ok { + return fmt.Errorf("tracker %s has duplicate rule authorization records", name) + } + seen[name] = struct{}{} + if _, err := AssessRuleFailures(name, failures[name], authorization.Rules); err != nil { + return err + } + } + return nil +} diff --git a/internal/trackers/rule_assessment_test.go b/internal/trackers/rule_assessment_test.go new file mode 100644 index 00000000..4fcaa91a --- /dev/null +++ b/internal/trackers/rule_assessment_test.go @@ -0,0 +1,107 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "reflect" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestAssessRuleFailuresRetainsMixedEvidenceAndExactAuthorization(t *testing.T) { + t.Parallel() + failures := []api.RuleFailure{ + { +Rule: "advice", + Reason: "recommended", + Disposition: api.RuleDispositionAdvisory, +}, + { +Rule: "container", + Reason: "container rejected", + Disposition: api.RuleDispositionWaivable, +}, + { +Rule: "resolution", + Reason: "resolution rejected", + Disposition: api.RuleDispositionStrict, +}, + } + assessment, err := AssessRuleFailures("example", failures, []string{"container"}) + if err != nil { + t.Fatalf("assess: %v", err) + } + if assessment.Eligible { + t.Fatal("strict failure became eligible after waivable authorization") + } + if len(assessment.Failures) != 3 || len(assessment.Advisory) != 1 || len(assessment.Waivable) != 1 || len(assessment.Strict) != 1 || len(assessment.Authorized) != 1 { + t.Fatalf("mixed assessment = %#v", assessment) + } + if !assessment.Decisions[1].Authorized || assessment.Decisions[2].Authorized { + t.Fatalf("authorization projection = %#v", assessment.Decisions) + } + + onlyWaivable, err := AssessRuleFailures("EXAMPLE", failures[1:2], []string{"container"}) + if err != nil || !onlyWaivable.Eligible || len(onlyWaivable.Failures) != 1 { + t.Fatalf("authorized waivable assessment = %#v, %v", onlyWaivable, err) + } +} + +func TestAssessRuleFailuresRejectsUnknownOrNonWaivableAuthorization(t *testing.T) { + t.Parallel() + failures := []api.RuleFailure{ + {Rule: "advice", Disposition: api.RuleDispositionAdvisory}, + {Rule: "strict", Disposition: api.RuleDispositionStrict}, + {Rule: "waivable", Disposition: api.RuleDispositionWaivable}, + } + for _, rule := range []string{"missing", "advice", "strict"} { + if _, err := AssessRuleFailures("EXAMPLE", failures, []string{rule}); err == nil { + t.Fatalf("authorization %q succeeded", rule) + } + } +} + +func TestValidateRuleAuthorizationsIsTrackerScopedAndRejectsDuplicates(t *testing.T) { + t.Parallel() + failures := map[string][]api.RuleFailure{ + "A": {{Rule: "container", Disposition: api.RuleDispositionWaivable}}, + "B": {{Rule: "language", Disposition: api.RuleDispositionWaivable}}, + } + valid := []api.RuleAuthorization{{Tracker: " a ", Rules: []string{"container"}}} + if err := ValidateRuleAuthorizations([]string{"A", "B"}, failures, valid); err != nil { + t.Fatalf("valid authorization: %v", err) + } + rules, err := AuthorizedRulesForTracker(valid, "A") + if err != nil || !reflect.DeepEqual(rules, []string{"container"}) { + t.Fatalf("authorized rules = %v, %v", rules, err) + } + if err := ValidateRuleAuthorizations([]string{"A", "B"}, failures, []api.RuleAuthorization{{Tracker: "A", Rules: []string{"language"}}}); err == nil { + t.Fatal("cross-tracker rule authorization succeeded") + } + if err := ValidateRuleAuthorizations([]string{"A"}, failures, []api.RuleAuthorization{{Tracker: "A"}, {Tracker: "a"}}); err == nil { + t.Fatal("duplicate tracker authorization records succeeded") + } + if err := ValidateRuleAuthorizations([]string{"A"}, failures, []api.RuleAuthorization{{Tracker: "B", Rules: []string{"language"}}}); err == nil { + t.Fatal("unselected tracker authorization succeeded") + } +} + +func TestAssessRuleFailuresNormalizesLegacyAndUnknownDisposition(t *testing.T) { + t.Parallel() + assessment, err := AssessRuleFailures("EXAMPLE", []api.RuleFailure{ + {Rule: "legacy_warning", Disposition: "warning"}, + {Rule: "legacy_blocking", Disposition: "blocking"}, + {Rule: "unknown", Disposition: "new_value"}, + }, nil) + if err != nil { + t.Fatalf("assess: %v", err) + } + want := []api.RuleDisposition{api.RuleDispositionAdvisory, api.RuleDispositionWaivable, api.RuleDispositionStrict} + for idx, failure := range assessment.Failures { + if failure.Disposition != want[idx] { + t.Fatalf("failure[%d] disposition=%q, want %q", idx, failure.Disposition, want[idx]) + } + } +} diff --git a/internal/trackers/rule_contract.go b/internal/trackers/rule_contract.go new file mode 100644 index 00000000..1800d9c4 --- /dev/null +++ b/internal/trackers/rule_contract.go @@ -0,0 +1,87 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "strings" + + "github.com/autobrr/upbrr/pkg/api" +) + +// RuleCheck returns keyed tracker-specific failures or an operational error. +// It may return multiple independent failures from one evaluation pass. +type RuleCheck func(ctx context.Context, meta api.RuleSubject, logger api.Logger) ([]api.RuleFailure, error) + +// NewRuleFailure constructs a normalized keyed tracker-rule result. +func NewRuleFailure(rule string, reason string, disposition api.RuleDisposition) api.RuleFailure { + return api.RuleFailure{ + Rule: strings.TrimSpace(rule), + Reason: strings.TrimSpace(reason), + Disposition: api.NormalizeRuleDisposition(disposition), + } +} + +// LanguageRule configures language requirements and the release types to which they apply. +type LanguageRule struct { + // Languages lists normalized languages accepted by the tracker. + Languages []string + // RequireAudio applies Languages to audio tracks. + RequireAudio bool + // RequireSubs applies Languages to subtitle tracks. + RequireSubs bool + // RequireBoth requires both accepted audio and subtitle tracks. + RequireBoth bool + // AllowOriginal accepts the release's original language independently of Languages. + AllowOriginal bool + // ApplyIfNonDisc limits the rule to non-disc releases. + ApplyIfNonDisc bool + // ApplyIfNonBDMV limits the rule to releases other than BDMV. + ApplyIfNonBDMV bool +} + +// RuleSet describes generic and tracker-specific validation applied before upload. +// Zero-valued fields disable their corresponding constraint. +type RuleSet struct { + // RequireUniqueID requires at least one supported external metadata identifier. + RequireUniqueID bool + // RequireValidMISetting requires a supported MediaInfo configuration. + RequireValidMISetting bool + // RequireAudioLanguages requires parsed audio-language metadata. + RequireAudioLanguages bool + // RequireDiscOnly rejects non-disc releases. + RequireDiscOnly bool + // RequireMovieOnly rejects non-movie releases. + RequireMovieOnly bool + // RequireMovieUnlessTVPack permits TV only when the release is a pack. + RequireMovieUnlessTVPack bool + // RequireTVOnly rejects non-TV releases. + RequireTVOnly bool + // RequireHEVCForTypes lists release types that must use HEVC video. + RequireHEVCForTypes []string + // MinResolution is the lowest accepted normalized resolution. + MinResolution string + // BlockAdult rejects metadata classified as adult content. + BlockAdult bool + // AdultMessage overrides the default adult-content failure text. + AdultMessage string + // Language configures audio and subtitle language constraints. + Language *LanguageRule + // BlockDVDRip rejects DVD rip release types. + BlockDVDRip bool + // BlockExternalSubs rejects releases with external subtitle files. + BlockExternalSubs bool + // BlockSingleFileFolder rejects a directory containing only one media file. + BlockSingleFileFolder bool + // BlockHardcodedSubs rejects releases detected with hardcoded subtitles. + BlockHardcodedSubs bool + // BlockGroups lists release groups rejected unconditionally. + BlockGroups []string + // BlockGroupUnlessType maps blocked groups to release types that remain allowed. + BlockGroupUnlessType map[string][]string + // RequireSceneNFO requires an NFO for scene releases. + RequireSceneNFO bool + // Check evaluates additional tracker-specific rules. + Check RuleCheck +} diff --git a/internal/trackers/rule_dispositions_test.go b/internal/trackers/rule_dispositions_test.go new file mode 100644 index 00000000..2c00d36f --- /dev/null +++ b/internal/trackers/rule_dispositions_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers_test + +import ( + "context" + "testing" + + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestRequestedTrackerSpecificRulesAreStrict(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tracker string + rule string + subject api.RuleSubject + }{ + { + name: "BHD container", + tracker: "BHD", + rule: "container", + subject: api.RuleSubject{Type: "REMUX", Container: "avi"}, + }, + { + name: "BLU container", + tracker: "BLU", + rule: "container", + subject: api.RuleSubject{Type: "WEBDL", Container: "avi"}, + }, + { + name: "LUME container", + tracker: "LUME", + rule: "container", + subject: api.RuleSubject{Container: "mp4", Release: api.ReleaseInfo{Resolution: "720p"}}, + }, + { + name: "TVC disc", + tracker: "TVC", + rule: "disc_forbidden", + subject: api.RuleSubject{DiscType: "BDMV", Release: api.ReleaseInfo{Resolution: "1080p"}}, + }, + { + name: "TVC remux", + tracker: "TVC", + rule: "remux_forbidden", + subject: api.RuleSubject{Type: "REMUX", Release: api.ReleaseInfo{Resolution: "1080p"}}, + }, + { + name: "SHRI region", + tracker: "SHRI", + rule: "region_required", + subject: api.RuleSubject{DiscType: "DVD"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + test.subject.Assessments.MediaInfoEncodeSettings = api.EncodeSettingsStatusPresent + failures := evaluateNonMetadataRulesForTest(context.Background(), test.tracker, test.subject) + failure, found := findRuleFailure(failures, test.rule) + if !found { + t.Fatalf("missing %s failure: %#v", test.rule, failures) + } + if failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("%s disposition = %q, want strict", test.rule, failure.Disposition) + } + }) + } +} + +func TestProductionMetadataIdentityRequirementsAreStrictExceptPTP(t *testing.T) { + t.Parallel() + + registry, err := impl.NewRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + checked := 0 + for _, tracker := range registry.Names() { + policy, ok := registry.LookupMetadataPolicy(tracker) + if !ok { + continue + } + for index, requirement := range policy.Requirements { + if !containsMetadataIdentityField(requirement.AnyOf) { + continue + } + checked++ + want := api.RuleDispositionStrict + if tracker == "PTP" { + want = api.RuleDispositionAdvisory + } + if requirement.Disposition != want { + t.Errorf("%s metadata requirement %d disposition = %q, want %q", tracker, index, requirement.Disposition, want) + } + } + } + if checked == 0 { + t.Fatal("no metadata identity requirements checked") + } +} + +func containsMetadataIdentityField(fields []trackers.MetadataField) bool { + for _, field := range fields { + switch field { + case trackers.MetadataFieldTMDBIDOnly, + trackers.MetadataFieldIMDBIDOnly, + trackers.MetadataFieldTVDBIDOnly, + trackers.MetadataFieldTVmazeIDOnly, + trackers.MetadataFieldTMDB, + trackers.MetadataFieldIMDB, + trackers.MetadataFieldTVDB, + trackers.MetadataFieldTVmaze: + return true + case trackers.MetadataFieldTVDBTitle, trackers.MetadataFieldPoster: + continue + } + } + return false +} diff --git a/internal/trackers/rules.go b/internal/trackers/rules.go index a990a713..6564f2b9 100644 --- a/internal/trackers/rules.go +++ b/internal/trackers/rules.go @@ -6,14 +6,11 @@ package trackers import ( "context" "fmt" - "strconv" "strings" - "time" "github.com/autobrr/upbrr/internal/languageutil" - "github.com/autobrr/upbrr/internal/pathutil" - "github.com/autobrr/upbrr/internal/trackers/impl/unit3d/additional" - "github.com/autobrr/upbrr/internal/trackers/unit3dmeta" + pathutil "github.com/autobrr/upbrr/internal/pathing" + "github.com/autobrr/upbrr/internal/releasepolicy" "github.com/autobrr/upbrr/pkg/api" ) @@ -31,81 +28,100 @@ var ruleResolutionOrder = map[string]int{ "8640p": 11, } -func EvaluateRules(ctx context.Context, tracker string, meta api.PreparedMetadata, logger api.Logger) []api.RuleFailure { +// EvaluateRules evaluates repository-wide release-modification policy without a +// tracker registry. Use [EvaluateRulesWithRegistry] for tracker and metadata +// policy. +func EvaluateRules(ctx context.Context, tracker string, meta api.RuleSubject, logger api.Logger) ([]api.RuleFailure, error) { + return evaluateRules(ctx, nil, tracker, meta, logger) +} + +// EvaluateRulesWithRegistry returns all applicable failures with their +// tracker-owned dispositions. Scene renames and structural requirements are +// strict; non-scene rename heuristics and explicitly waivable rules require +// authorization, while metadata requirements retain their site-specific +// disposition. +func EvaluateRulesWithRegistry(ctx context.Context, registry *Registry, tracker string, meta api.RuleSubject, logger api.Logger) ([]api.RuleFailure, error) { + return evaluateRules(ctx, registry, tracker, meta, logger) +} + +func evaluateRules(ctx context.Context, registry *Registry, tracker string, meta api.RuleSubject, logger api.Logger) ([]api.RuleFailure, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("trackers: evaluate rules: %w", err) + } name := strings.ToUpper(strings.TrimSpace(tracker)) failures := make([]api.RuleFailure, 0) - addFailure := func(rule, reason string) { + addFailure := func(rule, reason string, disposition api.RuleDisposition) { trimmed := strings.TrimSpace(reason) if trimmed == "" { trimmed = "rule requirement not met" } - failures = append(failures, api.RuleFailure{Rule: rule, Reason: trimmed}) - } - - // Renamed/modified releases are rejected by trackers regardless of family, so - // evaluate this before the family-specific gates below. It is enabled for all - // trackers by default (skipsModifiedReleaseCheck exempts special cases) and is - // overridable via IgnoreTrackerRuleFailuresFor like any other rule failure. - if !skipsModifiedReleaseCheck(name) { - if renamed, reason := isRenamedRelease(meta); renamed { - addFailure("modified_release", reason) - } - } - metadataFailures, metadataEvaluated := evaluateMetadataRequirements(name, meta) + failures = append(failures, api.RuleFailure{ + Rule: strings.TrimSpace(rule), + Reason: trimmed, + Disposition: api.NormalizeRuleDisposition(disposition), + }) + } + addStrict := func(rule, reason string) { addFailure(rule, reason, api.RuleDispositionStrict) } + addWaivable := func(rule, reason string) { addFailure(rule, reason, api.RuleDispositionWaivable) } + + // Renamed/modified releases are rejected by every supported tracker. Scene + // renames are authoritative and remain strict; heuristic rename detections + // may be explicitly authorized as a non-resolution policy exception. + if renamed, reason := releasepolicy.DetectModifiedRelease(releasepolicy.ModifiedReleaseSubject{ + SourcePath: meta.SourcePath, + VideoPath: meta.VideoPath, + DiscType: meta.DiscType, + PersonalRelease: meta.PersonalRelease, + SceneRenamed: meta.SceneRenamed, + SceneRenamedReason: meta.SceneRenamedReason, + Release: meta.Release, + }); renamed { + disposition := api.RuleDispositionWaivable + if meta.SceneRenamed { + disposition = api.RuleDispositionStrict + } + addFailure("modified_release", reason, disposition) + } + metadataFailures, metadataEvaluated := evaluateMetadataRequirementsWithRegistry(registry, name, meta) failures = append(failures, metadataFailures...) - switch name { - case "AZ", "CZ", "PHD": - return append(failures, evaluateAZFamilyRules(name, meta)...) - } - rules, ok := additional.RulesFor(name) - // UNIT3D-known trackers without a tracker-specific RuleSet must still reach - // the MediaInfo-settings check below (rules is the zero value for them, which - // no-ops every other rule), so don't bail early when the tracker is known. - if !ok && name != "PTP" && !unit3dmeta.IsKnown(name) && !metadataEvaluated { + rules, ok := registry.LookupRules(name) + if !ok && !metadataEvaluated { // Preserve the nil contract for trackers without their own rule set: the // consumer (applyTrackerRules) treats a nil result as "not evaluated, keep // pre-existing failures" but an empty slice as "evaluated, clear failures". // Only return a slice when this rule actually produced a failure. if len(failures) > 0 { - return failures + return failures, nil } - return nil + return nil, nil } - if rules.RequireUniqueID && !meta.ValidMediaInfo { - addFailure("require_unique_id", "missing MediaInfo Unique ID") + if rules.RequireUniqueID && !meta.Assessments.UniqueIDRequirementSatisfied() { + addStrict("require_unique_id", "missing MediaInfo Unique ID") } - // Every UNIT3D upload rejects encodes that lack MediaInfo encoding settings - // (see internal/trackers/impl/unit3d/upload.go), so enforce it at - // metadata-prep time for all UNIT3D trackers instead of relying on each - // RuleSet to opt in. This lets the release be skipped before - // screenshots/torrent/upload rather than failing at the upload step. - // ValidMediaInfoSettings is only false for encodes missing settings, so - // remux/web/disc uploads are unaffected. The per-tracker flag still covers - // non-UNIT3D trackers (e.g. BHD) that opt in via their RuleSet. - if (rules.RequireValidMISetting || unit3dmeta.IsKnown(name)) && !meta.ValidMediaInfoSettings { - addFailure("require_valid_mi_setting", "missing MediaInfo encode settings") + if rules.RequireValidMISetting && !meta.Assessments.EncodeSettingsRequirementSatisfied() { + addStrict("require_valid_mi_setting", "missing MediaInfo encode settings") } if rules.RequireDiscOnly && !isDiscType(meta.DiscType) { - addFailure("require_disc_only", "requires disc upload") + addStrict("require_disc_only", "requires disc upload") } - if name == "PTP" && !meta.TVPack { + if rules.RequireMovieUnlessTVPack && !meta.TVPack { category := resolveCategory(meta) if category != "" && category != "movie" { - addFailure("require_movie_only", fmt.Sprintf("category %s is not movie", category)) + addStrict("require_movie_only", fmt.Sprintf("category %s is not movie", category)) } } if rules.RequireMovieOnly || rules.RequireTVOnly { category := resolveCategory(meta) if category != "" { if rules.RequireMovieOnly && category != "movie" { - addFailure("require_movie_only", fmt.Sprintf("category %s is not movie", category)) + addStrict("require_movie_only", fmt.Sprintf("category %s is not movie", category)) } if rules.RequireTVOnly && category != "tv" { - addFailure("require_tv_only", fmt.Sprintf("category %s is not tv", category)) + addStrict("require_tv_only", fmt.Sprintf("category %s is not tv", category)) } } else if logger != nil { logger.Debugf("trackers: %s rule category check skipped (missing category)", name) @@ -115,7 +131,7 @@ func EvaluateRules(ctx context.Context, tracker string, meta api.PreparedMetadat typeValue := resolveType(meta) if len(rules.RequireHEVCForTypes) > 0 { if hasTypeRequirement(typeValue, rules.RequireHEVCForTypes) && !isHEVC(meta) { - addFailure("require_hevc", fmt.Sprintf("%s requires HEVC for %s", name, typeValue)) + addStrict("require_hevc", fmt.Sprintf("%s requires HEVC for %s", name, typeValue)) } } @@ -123,9 +139,9 @@ func EvaluateRules(ctx context.Context, tracker string, meta api.PreparedMetadat minResolution := strings.ToLower(strings.TrimSpace(rules.MinResolution)) value := resolveResolution(meta) if value == "" { - addFailure("min_resolution", "resolution required for "+name) + addFailure("min_resolution", "resolution required for "+name, api.RuleDispositionStrict) } else if ruleResolutionOrder[value] < ruleResolutionOrder[minResolution] { - addFailure("min_resolution", fmt.Sprintf("resolution %s below %s", value, minResolution)) + addFailure("min_resolution", fmt.Sprintf("resolution %s below %s", value, minResolution), api.RuleDispositionStrict) } } @@ -134,27 +150,27 @@ func EvaluateRules(ctx context.Context, tracker string, meta api.PreparedMetadat if message == "" { message = "adult content not allowed at " + name } - addFailure("block_adult", message) + addWaivable("block_adult", message) } if rules.BlockDVDRip && strings.EqualFold(typeValue, "DVDRIP") { - addFailure("block_dvdrip", "DVDRip not allowed") + addStrict("block_dvdrip", "DVDRip not allowed") } if rules.BlockExternalSubs && hasReleaseToken(meta, []string{"extsub", "ext-sub", "external subs", "external subtitles"}) { - addFailure("block_external_subs", "external subtitles not allowed") + addWaivable("block_external_subs", "external subtitles not allowed") } if rules.BlockHardcodedSubs && hasReleaseToken(meta, []string{"hardsub", "hard-sub", "hardcoded"}) { - addFailure("block_hardcoded_subs", "hardcoded subtitles not allowed") + addStrict("block_hardcoded_subs", "hardcoded subtitles not allowed") } if rules.BlockSingleFileFolder && hasSingleFileFolder(meta) { - addFailure("block_single_file_folder", "single-file folders are not allowed") + addStrict("block_single_file_folder", "single-file folders are not allowed") } if len(rules.BlockGroups) > 0 { group := strings.ToUpper(strings.TrimSpace(resolveGroup(meta))) if group != "" && containsAny([]string{group}, rules.BlockGroups) { - addFailure("block_group", fmt.Sprintf("group %s not allowed", group)) + addWaivable("block_group", fmt.Sprintf("group %s not allowed", group)) } } @@ -163,239 +179,61 @@ func EvaluateRules(ctx context.Context, tracker string, meta api.PreparedMetadat if group != "" { if allowedTypes, ok := rules.BlockGroupUnlessType[group]; ok { if !hasTypeRequirement(typeValue, allowedTypes) { - addFailure("block_group_unless_type", fmt.Sprintf("group %s only allowed for %s", group, strings.Join(allowedTypes, ", "))) + addWaivable("block_group_unless_type", fmt.Sprintf("group %s only allowed for %s", group, strings.Join(allowedTypes, ", "))) } } } } if rules.RequireSceneNFO && meta.Scene && strings.TrimSpace(meta.SceneNFOPath) == "" { - addFailure("require_scene_nfo", "scene release missing NFO") + addStrict("require_scene_nfo", "scene release missing NFO") } if rules.RequireAudioLanguages && len(meta.AudioLanguages) == 0 { - addFailure("require_audio_languages", "missing audio language data") + addStrict("require_audio_languages", "missing audio language data") } if rules.Language != nil { if ok, reason := evaluateLanguageRule(meta, rules.Language); !ok { - addFailure("language_rule", reason) + addStrict("language_rule", reason) } } - if rules.ExtraCheck != nil { - result := rules.ExtraCheck(ctx, meta, logger) - if !result.Allowed { - addFailure("extra_check", result.Reason) - } - } - - return failures -} - -func evaluateAZFamilyRules(name string, meta api.PreparedMetadata) []api.RuleFailure { - failures := make([]api.RuleFailure, 0) - add := func(rule, reason string) { - failures = append(failures, api.RuleFailure{Rule: rule, Reason: reason}) - } - - category := strings.ToUpper(strings.TrimSpace(resolveCategory(meta))) - if category != "MOVIE" && category != "TV" { - add("content_type", "only movies and TV shows are allowed") - } - if meta.Anime { - add("anime_redirect", "anime should be uploaded to AnimeTorrents instead") - } - - origin := originCountries(meta) - switch name { - case "AZ": - if intersects(origin, phdCountries()) { - add("country_redirect", "major English-language content belongs on PrivateHD") - } else if intersects(origin, cinemaZCountries()) { - add("country_redirect", "non-Asian western content belongs on CinemaZ") - } - case "CZ": - switch { - case intersects(origin, phdCountries()) && !isOlderThan50Years(meta): - add("country_redirect", "recent mainstream English content belongs on PrivateHD") - case intersects(origin, azCountries()): - add("country_redirect", "Asian content belongs on AvistaZ") - case len(origin) > 0 && !intersects(origin, czAllowedCountries()): - add("country_block", "content origin is outside CinemaZ allowed regions") - } - case "PHD": - if isOlderThan50Years(meta) { - add("age_redirect", "50+ year-old content belongs on CinemaZ") + if rules.Check != nil { + customFailures, err := rules.Check(ctx, meta, logger) + if err != nil { + return nil, fmt.Errorf("trackers: %s rule check: %w", name, err) } - switch { - case intersects(origin, cinemaZCountries()): - add("country_redirect", "European, South American, and African content belongs on CinemaZ") - case intersects(origin, azCountries()): - add("country_redirect", "Asian content belongs on AvistaZ") - case len(origin) > 0 && !intersects(origin, phdCountries()): - add("country_block", "PrivateHD only allows major English-language territories") + for _, failure := range customFailures { + addFailure(failure.Rule, failure.Reason, failure.Disposition) } } - if name == "PHD" { - evaluatePHDTechnicalRules(meta, add) - } - - return failures + return failures, nil } -func evaluatePHDTechnicalRules(meta api.PreparedMetadata, add func(string, string)) { - if strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "480p") || - strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "576p") || - strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "480i") || - strings.EqualFold(strings.TrimSpace(meta.Release.Resolution), "576i") { - add("sd_forbidden", "SD content is forbidden") - } +// ResolveRuleCategory returns the common category used by tracker rules. +func ResolveRuleCategory(meta api.RuleSubject) string { return resolveCategory(meta) } - if !isDiscType(meta.DiscType) { - container := strings.ToLower(strings.TrimSpace(meta.Container)) - if container != "" && container != "mkv" && container != "mp4" { - add("container", "allowed containers: MKV, MP4") - } - } +// ResolveRuleType returns the common release type used by tracker rules. +func ResolveRuleType(meta api.RuleSubject) string { return resolveType(meta) } - group := strings.ToUpper(strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-")) - switch group { - case "RARBG", "FGT", "GRYM", "TBS": - add("group_block", "RARBG, FGT, Grym, and TBS are not allowed") - } - if group == "EVO" && !strings.EqualFold(strings.TrimSpace(meta.Source), "WEB") { - add("group_block", "non-web EVO releases are not allowed") - } +// ResolveRuleResolution returns the common resolution used by tracker rules. +func ResolveRuleResolution(meta api.RuleSubject) string { return resolveResolution(meta) } - codec := strings.ToLower(strings.TrimSpace(meta.VideoCodec)) - encode := strings.ToLower(strings.TrimSpace(meta.VideoEncode)) - releaseType := strings.ToLower(strings.TrimSpace(resolveType(meta))) - source := strings.ToLower(strings.TrimSpace(meta.Source)) - bitDepth := strings.TrimSpace(meta.BitDepth) +// IsDiscType reports whether value identifies a supported disc source. +func IsDiscType(value string) bool { return isDiscType(value) } - if releaseType == "remux" && codec != "" && codec != "mpeg-2" && codec != "vc-1" && codec != "h.264" && codec != "h.265" && codec != "avc" { - add("video_codec", "BluRay remuxes require MPEG-2, VC-1, H.264, or H.265") - } - if releaseType == "encode" && strings.Contains(source, "bluray") && encode != "" && encode != "h.264" && encode != "h.265" && encode != "x264" && encode != "x265" { - add("video_encode", "BluRay encodes require H.264/H.265 with x264/x265") - } - if (releaseType == "webdl" || releaseType == "web-dl") && source == "web" && encode != "" && encode != "h.264" && encode != "h.265" && encode != "vp9" { - add("video_encode", "WEB-DL requires H.264, H.265, or VP9") - } - if releaseType == "encode" && source == "web" && encode != "" && encode != "h.264" && encode != "h.265" && encode != "x264" && encode != "x265" { - add("video_encode", "WEB encodes require H.264/H.265 with x264/x265") - } - if releaseType == "encode" && encode == "x265" && bitDepth != "10" { - add("bit_depth", "x265 encodes must be 10-bit") - } - if res := strings.ToLower(strings.TrimSpace(resolveResolution(meta))); strings.HasSuffix(res, "p") { - value := strings.TrimSuffix(res, "p") - if height, err := strconv.Atoi(value); err == nil && height > 1080 && (encode == "h.264" || encode == "x264") { - add("video_encode", "H.264/x264 is only allowed for 1080p and below") - } - } -} - -func originCountries(meta api.PreparedMetadata) []string { - if meta.ExternalMetadata.TMDB != nil && len(meta.ExternalMetadata.TMDB.OriginCountry) > 0 { - return meta.ExternalMetadata.TMDB.OriginCountry - } - return nil -} - -func isOlderThan50Years(meta api.PreparedMetadata) bool { - year := meta.Release.Year - if year == 0 && meta.ExternalMetadata.TMDB != nil { - year = meta.ExternalMetadata.TMDB.Year - } - return year > 0 && time.Now().UTC().Year()-year >= 50 -} - -func intersects(left []string, right map[string]struct{}) bool { - for _, value := range left { - if _, ok := right[strings.ToUpper(strings.TrimSpace(value))]; ok { - return true - } - } - return false -} - -func azCountries() map[string]struct{} { - return countrySet("BD", "BN", "BT", "CN", "HK", "ID", "IN", "JP", "KH", "KP", "KR", "LA", "LK", "MM", "MN", "MO", "MY", "NP", "PH", "PK", "SG", "TH", "TL", "TW", "VN") -} - -func phdCountries() map[string]struct{} { - return countrySet("AG", "AI", "AU", "BB", "BM", "BS", "BZ", "CA", "CW", "DM", "GB", "GD", "IE", "JM", "KN", "KY", "LC", "MS", "NZ", "PR", "TC", "TT", "US", "VC", "VG", "VI") -} - -func cinemaZCountries() map[string]struct{} { - all := countrySet("AO", "BF", "BI", "BJ", "BW", "CD", "CF", "CG", "CI", "CM", "CV", "DJ", "DZ", "EG", "EH", "ER", "ET", "GA", "GH", "GM", "GN", "GQ", "GW", "IO", "KE", "KM", "LR", "LS", "LY", "MA", "MG", "ML", "MR", "MU", "MW", "MZ", "NA", "NE", "NG", "RE", "RW", "SC", "SD", "SH", "SL", "SN", "SO", "SS", "ST", "SZ", "TD", "TF", "TG", "TN", "TZ", "UG", "YT", "ZA", "ZM", "ZW", - "AG", "AI", "AR", "AW", "BB", "BL", "BM", "BO", "BQ", "BR", "BS", "BV", "BZ", "CA", "CL", "CO", "CR", "CU", "CW", "DM", "DO", "EC", "FK", "GD", "GF", "GL", "GP", "GS", "GT", "GY", "HN", "HT", "JM", "KN", "KY", "LC", "MF", "MQ", "MS", "MX", "NI", "PA", "PE", "PM", "PR", "PY", "SR", "SV", "SX", "TC", "TT", "US", "UY", "VC", "VE", "VG", "VI", - "AD", "AL", "AT", "AX", "BA", "BE", "BG", "BY", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", "FO", "FR", "GB", "GG", "GI", "GR", "HR", "HU", "IE", "IM", "IS", "IT", "JE", "LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT", "RO", "RS", "RU", "SE", "SI", "SJ", "SK", "SM", "SU", "UA", "VA", "XC", - "AS", "AU", "CC", "CK", "CX", "FJ", "FM", "GU", "HM", "KI", "MH", "MP", "NC", "NF", "NR", "NU", "NZ", "PF", "PG", "PN", "PW", "SB", "TK", "TO", "TV", "UM", "VU", "WF", "WS") - for code := range phdCountries() { - delete(all, code) - } - for code := range azCountries() { - delete(all, code) - } - return all -} - -func czAllowedCountries() map[string]struct{} { - return countrySet("AD", "AL", "AT", "AX", "BA", "BE", "BG", "BY", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", "FO", "FR", "GI", "GR", "HR", "HU", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT", "RO", "RS", "RU", "SE", "SI", "SJ", "SK", "SM", "SU", "UA", "VA", "XC", - "AG", "AI", "AR", "AW", "BL", "BO", "BQ", "BR", "BV", "CL", "CO", "CU", "DO", "EC", "FK", "GF", "GL", "GP", "GS", "GT", "GY", "HN", "HT", "MF", "MQ", "MX", "NI", "PA", "PE", "PM", "PY", "SR", "SV", "SX", "UY", "VE", - "AO", "BF", "BI", "BJ", "BW", "CD", "CF", "CG", "CI", "CM", "CV", "DJ", "DZ", "EG", "EH", "ER", "ET", "GA", "GH", "GM", "GN", "GQ", "GW", "IO", "KE", "KM", "LR", "LS", "LY", "MA", "MG", "ML", "MR", "MU", "MW", "MZ", "NA", "NE", "NG", "RE", "RW", "SC", "SD", "SH", "SL", "SN", "SO", "SS", "ST", "SZ", "TD", "TF", "TG", "TN", "TZ", "UG", "YT", "ZA", "ZM", "ZW", - "AE", "BH", "CY", "IR", "IQ", "IL", "JO", "KW", "LB", "OM", "PS", "QA", "SA", "SY", "TR", "YE") -} - -func countrySet(values ...string) map[string]struct{} { - out := make(map[string]struct{}, len(values)) - for _, value := range values { - out[value] = struct{}{} - } - return out -} - -func resolveCategory(meta api.PreparedMetadata) string { - if sourceMatches(meta.ExternalIDs.SourcePath, meta.SourcePath) { - if value := strings.ToLower(strings.TrimSpace(meta.ExternalIDs.Category)); value != "" { +func resolveCategory(meta api.RuleSubject) string { + if sourceMatches(meta.Identity.SourcePath, meta.SourcePath) { + if value := strings.ToLower(strings.TrimSpace(string(meta.Identity.Category))); value != "" && value != string(api.CanonicalCategoryUnknown) { return value } } - if value := strings.ToLower(strings.TrimSpace(meta.MediaInfoCategory)); value != "" { - return value - } - if sourceMatches(meta.ExternalMetadata.SourcePath, meta.SourcePath) { - if meta.ExternalMetadata.TMDB != nil { - if value := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.TMDB.Category)); value != "" { - return value - } - } - if (meta.ExternalMetadata.TVDB != nil && - (strings.TrimSpace(meta.ExternalMetadata.TVDB.Name) != "" || strings.TrimSpace(meta.ExternalMetadata.TVDB.NameEnglish) != "")) || - (meta.ExternalMetadata.TVmaze != nil && strings.TrimSpace(meta.ExternalMetadata.TVmaze.Name) != "") { - return "tv" - } - if meta.ExternalMetadata.IMDB != nil { - typeValue := strings.ToLower(strings.TrimSpace(meta.ExternalMetadata.IMDB.Type)) - switch { - case strings.Contains(typeValue, "tv"), strings.Contains(typeValue, "series"), strings.Contains(typeValue, "episode"): - return "tv" - case strings.Contains(typeValue, "movie"), strings.Contains(typeValue, "film"), strings.Contains(typeValue, "feature"): - return "movie" - } - } - } - if value := strings.ToLower(strings.TrimSpace(meta.Release.Category)); value != "" { - return value - } return "" } -func resolveType(meta api.PreparedMetadata) string { +func resolveType(meta api.RuleSubject) string { value := strings.ToUpper(strings.TrimSpace(meta.Type)) if value == "" { value = strings.ToUpper(strings.TrimSpace(meta.Release.Type)) @@ -403,14 +241,14 @@ func resolveType(meta api.PreparedMetadata) string { return value } -func resolveGroup(meta api.PreparedMetadata) string { +func resolveGroup(meta api.RuleSubject) string { if group := strings.TrimSpace(meta.Release.Group); group != "" { return group } return strings.TrimPrefix(strings.TrimSpace(meta.Tag), "-") } -func resolveResolution(meta api.PreparedMetadata) string { +func resolveResolution(meta api.RuleSubject) string { resolution := strings.TrimSpace(meta.Release.Resolution) if resolution == "" { resolution = detectResolution(meta.ReleaseName) @@ -437,7 +275,7 @@ func isDiscType(value string) bool { } } -func isHEVC(meta api.PreparedMetadata) bool { +func isHEVC(meta api.RuleSubject) bool { codec := strings.ToUpper(strings.TrimSpace(meta.VideoCodec)) if codec == "" { for _, value := range meta.Release.Codec { @@ -462,7 +300,7 @@ func hasTypeRequirement(value string, allowed []string) bool { return false } -func hasSingleFileFolder(meta api.PreparedMetadata) bool { +func hasSingleFileFolder(meta api.RuleSubject) bool { if isDiscType(meta.DiscType) { return false } @@ -472,7 +310,7 @@ func hasSingleFileFolder(meta api.PreparedMetadata) bool { return !strings.EqualFold(strings.TrimSpace(meta.FileList[0]), strings.TrimSpace(meta.SourcePath)) } -func hasReleaseToken(meta api.PreparedMetadata, tokens []string) bool { +func hasReleaseToken(meta api.RuleSubject, tokens []string) bool { values := make([]string, 0, len(meta.Release.Other)+len(meta.Release.Edition)+2) values = append(values, meta.Release.Other...) values = append(values, meta.Release.Edition...) @@ -494,14 +332,14 @@ func hasReleaseToken(meta api.PreparedMetadata, tokens []string) bool { return false } -func isAdultContent(meta api.PreparedMetadata) bool { +func isAdultContent(meta api.RuleSubject) bool { candidates := append([]string{}, splitCSV(meta.Release.Genre)...) - if meta.ExternalMetadata.TMDB != nil && externalMetadataMatchesCurrentSource(meta) { - candidates = append(candidates, splitCSV(meta.ExternalMetadata.TMDB.Genres)...) - candidates = append(candidates, splitCSV(meta.ExternalMetadata.TMDB.Keywords)...) + if meta.ProviderMetadata.TMDB != nil && externalMetadataMatchesCurrentSource(meta) { + candidates = append(candidates, splitCSV(meta.ProviderMetadata.TMDB.Genres)...) + candidates = append(candidates, splitCSV(meta.ProviderMetadata.TMDB.Keywords)...) } - if meta.ExternalMetadata.IMDB != nil && externalMetadataMatchesCurrentSource(meta) { - candidates = append(candidates, splitCSV(meta.ExternalMetadata.IMDB.Genres)...) + if meta.ProviderMetadata.IMDB != nil && externalMetadataMatchesCurrentSource(meta) { + candidates = append(candidates, splitCSV(meta.ProviderMetadata.IMDB.Genres)...) } normalized := normalizeStrings(candidates) for _, token := range normalized { @@ -513,8 +351,8 @@ func isAdultContent(meta api.PreparedMetadata) bool { return false } -func externalMetadataMatchesCurrentSource(meta api.PreparedMetadata) bool { - storedSource := strings.TrimSpace(meta.ExternalMetadata.SourcePath) +func externalMetadataMatchesCurrentSource(meta api.RuleSubject) bool { + storedSource := strings.TrimSpace(meta.ProviderMetadata.SourcePath) return storedSource == "" || pathutil.SamePath(storedSource, strings.TrimSpace(meta.SourcePath)) } @@ -566,7 +404,7 @@ func containsAny(values []string, targets []string) bool { return false } -func evaluateLanguageRule(meta api.PreparedMetadata, rule *additional.LanguageRule) (bool, string) { +func evaluateLanguageRule(meta api.RuleSubject, rule *LanguageRule) (bool, string) { if rule == nil { return true, "" } @@ -633,13 +471,13 @@ func evaluateLanguageRule(meta api.PreparedMetadata, rule *additional.LanguageRu return false, "requires audio or subtitles in " + strings.Join(required, ", ") } -func resolveOriginalLanguage(meta api.PreparedMetadata) string { +func resolveOriginalLanguage(meta api.RuleSubject) string { var raw string - if meta.ExternalMetadata.TMDB != nil { - raw = strings.TrimSpace(meta.ExternalMetadata.TMDB.OriginalLanguage) + if meta.ProviderMetadata.TMDB != nil { + raw = strings.TrimSpace(meta.ProviderMetadata.TMDB.OriginalLanguage) } - if raw == "" && meta.ExternalMetadata.IMDB != nil { - raw = strings.TrimSpace(meta.ExternalMetadata.IMDB.OriginalLanguage) + if raw == "" && meta.ProviderMetadata.IMDB != nil { + raw = strings.TrimSpace(meta.ProviderMetadata.IMDB.OriginalLanguage) } normalized := languageutil.NormalizeLanguageDisplay(raw) if normalized == "" { diff --git a/internal/trackers/rules_internal_test.go b/internal/trackers/rules_internal_test.go new file mode 100644 index 00000000..09657bac --- /dev/null +++ b/internal/trackers/rules_internal_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestResolveCategoryUsesCanonicalIdentityDespiteEmptyTVMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata api.SourceScopedMetadata + }{ + {name: "TVDB", metadata: api.SourceScopedMetadata{TVDB: &api.TVDBMetadata{}}}, + {name: "TVmaze", metadata: api.SourceScopedMetadata{TVmaze: &api.TVmazeMetadata{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: api.CanonicalCategoryMovie}, + ProviderMetadata: tt.metadata, + } + if got := resolveCategory(meta); got != "movie" { + t.Fatalf("expected canonical movie category, got %q", got) + } + }) + } +} + +func TestConfiguredStrictRuleDispositions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rule string + rules RuleSet + subject api.RuleSubject + }{ + { + name: "unique ID", + rule: "require_unique_id", + rules: RuleSet{RequireUniqueID: true}, + subject: api.RuleSubject{Assessments: api.ReleaseAssessments{ + MediaInfoUniqueID: api.UniqueIDStatusMissing, + }}, + }, + { + name: "MediaInfo settings", + rule: "require_valid_mi_setting", + rules: RuleSet{RequireValidMISetting: true}, + subject: api.RuleSubject{Assessments: api.ReleaseAssessments{ + MediaInfoEncodeSettings: api.EncodeSettingsStatusMissing, + }}, + }, + { + name: "disc only", + rule: "require_disc_only", + rules: RuleSet{RequireDiscOnly: true}, + subject: api.RuleSubject{}, + }, + { + name: "movie only", + rule: "require_movie_only", + rules: RuleSet{RequireMovieOnly: true}, + subject: api.RuleSubject{Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryTV, + }}, + }, + { + name: "movie unless TV pack", + rule: "require_movie_only", + rules: RuleSet{RequireMovieUnlessTVPack: true}, + subject: api.RuleSubject{Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryTV, + }}, + }, + { + name: "TV only", + rule: "require_tv_only", + rules: RuleSet{RequireTVOnly: true}, + subject: api.RuleSubject{Identity: api.ExternalIdentity{ + Category: api.CanonicalCategoryMovie, + }}, + }, + { + name: "HEVC", + rule: "require_hevc", + rules: RuleSet{RequireHEVCForTypes: []string{"ENCODE"}}, + subject: api.RuleSubject{ + Type: "ENCODE", + VideoCodec: "AVC", + }, + }, + { + name: "DVDRip", + rule: "block_dvdrip", + rules: RuleSet{BlockDVDRip: true}, + subject: api.RuleSubject{Type: "DVDRIP"}, + }, + { + name: "hardcoded subtitles", + rule: "block_hardcoded_subs", + rules: RuleSet{BlockHardcodedSubs: true}, + subject: api.RuleSubject{ReleaseName: "Example.Release.2026.HARDSUB-GRP"}, + }, + { + name: "single-file folder", + rule: "block_single_file_folder", + rules: RuleSet{BlockSingleFileFolder: true}, + subject: api.RuleSubject{ + SourcePath: "Example.Release.2026", + FileList: []string{"Example.Release.2026/video.mkv"}, + }, + }, + { + name: "scene NFO", + rule: "require_scene_nfo", + rules: RuleSet{RequireSceneNFO: true}, + subject: api.RuleSubject{ + Scene: true, + }, + }, + { + name: "audio language metadata", + rule: "require_audio_languages", + rules: RuleSet{RequireAudioLanguages: true}, + subject: api.RuleSubject{}, + }, + { + name: "language requirement", + rule: "language_rule", + rules: RuleSet{Language: &LanguageRule{ + Languages: []string{"english"}, + RequireAudio: true, + }}, + subject: api.RuleSubject{AudioLanguages: []string{"french"}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + registry := NewRegistry() + if err := registry.RegisterDescriptor(Descriptor{ + Name: "STRICT", + Definition: stubDefinition{name: "STRICT"}, + Rules: &test.rules, + }); err != nil { + t.Fatalf("register rule: %v", err) + } + failures, err := evaluateRules(context.Background(), registry, "STRICT", test.subject, nil) + if err != nil { + t.Fatalf("evaluate rule: %v", err) + } + for _, failure := range failures { + if failure.Rule == test.rule { + if failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("%s disposition = %q, want strict", test.rule, failure.Disposition) + } + return + } + } + t.Fatalf("missing %s failure: %#v", test.rule, failures) + }) + } +} + +func TestMetadataCategoryDispositionUsesStrictestRequirement(t *testing.T) { + t.Parallel() + + policy := TrackerMetadataPolicy{Requirements: []MetadataRequirement{ + { + Scope: MetadataScopeAny, + AnyOf: []MetadataField{MetadataFieldPoster}, + Disposition: api.RuleDispositionWaivable, + }, + { + Scope: MetadataScopeMovie, + AnyOf: []MetadataField{MetadataFieldTMDB}, + Disposition: api.RuleDispositionStrict, + }, + }} + if got := metadataCategoryDisposition(policy); got != api.RuleDispositionStrict { + t.Fatalf("category disposition = %q, want strict", got) + } +} diff --git a/internal/trackers/rules_test.go b/internal/trackers/rules_test.go index e34c22cf..886979ee 100644 --- a/internal/trackers/rules_test.go +++ b/internal/trackers/rules_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026, Audionut and the autobrr contributors. // SPDX-License-Identifier: GPL-2.0-or-later -package trackers +package trackers_test import ( "context" @@ -10,55 +10,77 @@ import ( "strings" "testing" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/trackers/impl" "github.com/autobrr/upbrr/pkg/api" ) -func evaluateNonMetadataRulesForTest(ctx context.Context, tracker string, meta api.PreparedMetadata) []api.RuleFailure { - if strings.TrimSpace(meta.ExternalIDs.Category) == "" { +func evaluateNonMetadataRulesForTest(ctx context.Context, tracker string, meta api.RuleSubject) []api.RuleFailure { + if strings.TrimSpace(string(meta.Identity.Category)) == "" { switch strings.ToUpper(strings.TrimSpace(tracker)) { case "NBL", "BTN": - meta.ExternalIDs.Category = "TV" + meta.Identity.Category = api.CanonicalCategoryTV default: - meta.ExternalIDs.Category = "MOVIE" + meta.Identity.Category = api.CanonicalCategoryMovie } } - meta.ExternalIDs.TMDBID = 1 - meta.ExternalIDs.IMDBID = 1234567 - meta.ExternalIDs.TVDBID = 1 - meta.ExternalIDs.TVmazeID = 1 - if meta.ExternalMetadata.TMDB == nil { - meta.ExternalMetadata.TMDB = &api.TMDBMetadata{} + meta.Identity.TMDBID = 1 + meta.Identity.IMDBID = 1234567 + meta.Identity.TVDBID = 1 + meta.Identity.TVmazeID = 1 + if meta.ProviderMetadata.TMDB == nil { + meta.ProviderMetadata.TMDB = &api.TMDBMetadata{} } - meta.ExternalMetadata.TMDB.TMDBID = 1 - if strings.TrimSpace(meta.ExternalMetadata.TMDB.Title) == "" { - meta.ExternalMetadata.TMDB.Title = "Example Release" + meta.ProviderMetadata.TMDB.TMDBID = 1 + if strings.TrimSpace(meta.ProviderMetadata.TMDB.Title) == "" { + meta.ProviderMetadata.TMDB.Title = "Example Release" } - if meta.ExternalMetadata.IMDB == nil { - meta.ExternalMetadata.IMDB = &api.IMDBMetadata{} + if meta.ProviderMetadata.IMDB == nil { + meta.ProviderMetadata.IMDB = &api.IMDBMetadata{} } - meta.ExternalMetadata.IMDB.IMDBID = 1234567 - if strings.TrimSpace(meta.ExternalMetadata.IMDB.Title) == "" { - meta.ExternalMetadata.IMDB.Title = "Example Release" + meta.ProviderMetadata.IMDB.IMDBID = 1234567 + if strings.TrimSpace(meta.ProviderMetadata.IMDB.Title) == "" { + meta.ProviderMetadata.IMDB.Title = "Example Release" } - if meta.ExternalMetadata.TVDB == nil { - meta.ExternalMetadata.TVDB = &api.TVDBMetadata{} + if meta.ProviderMetadata.TVDB == nil { + meta.ProviderMetadata.TVDB = &api.TVDBMetadata{} } - meta.ExternalMetadata.TVDB.TVDBID = 1 - if strings.TrimSpace(meta.ExternalMetadata.TVDB.Name) == "" { - meta.ExternalMetadata.TVDB.Name = "Example Series" + meta.ProviderMetadata.TVDB.TVDBID = 1 + if strings.TrimSpace(meta.ProviderMetadata.TVDB.Name) == "" { + meta.ProviderMetadata.TVDB.Name = "Example Series" } - if meta.ExternalMetadata.TVmaze == nil { - meta.ExternalMetadata.TVmaze = &api.TVmazeMetadata{} + if meta.ProviderMetadata.TVmaze == nil { + meta.ProviderMetadata.TVmaze = &api.TVmazeMetadata{} } - meta.ExternalMetadata.TVmaze.TVmazeID = 1 - if strings.TrimSpace(meta.ExternalMetadata.TVmaze.Name) == "" { - meta.ExternalMetadata.TVmaze.Name = "Example Series" + meta.ProviderMetadata.TVmaze.TVmazeID = 1 + if strings.TrimSpace(meta.ProviderMetadata.TVmaze.Name) == "" { + meta.ProviderMetadata.TVmaze.Name = "Example Series" } - return EvaluateRules(ctx, tracker, meta, nil) + registry, err := impl.NewRegistry() + if err != nil { + panic(err) + } + failures, err := trackers.EvaluateRulesWithRegistry(ctx, registry, tracker, meta, nil) + if err != nil { + panic(err) + } + return failures +} + +func evaluateBHDRulesWithRegistryForTest(ctx context.Context, meta api.RuleSubject) []api.RuleFailure { + registry, err := impl.NewRegistry() + if err != nil { + panic(err) + } + failures, err := trackers.EvaluateRulesWithRegistry(ctx, registry, "BHD", meta, nil) + if err != nil { + panic(err) + } + return failures } func TestEvaluateRulesRequiresUniqueID(t *testing.T) { - meta := api.PreparedMetadata{ValidMediaInfo: false} + meta := api.RuleSubject{Assessments: api.ReleaseAssessments{MediaInfoUniqueID: api.UniqueIDStatusMissing}} failures := evaluateNonMetadataRulesForTest(context.Background(), "AITHER", meta) if len(failures) == 0 { t.Fatalf("expected rule failure") @@ -68,31 +90,6 @@ func TestEvaluateRulesRequiresUniqueID(t *testing.T) { } } -func TestResolveCategoryIgnoresEmptyTVMetadata(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - metadata api.ExternalMetadata - }{ - {name: "TVDB", metadata: api.ExternalMetadata{TVDB: &api.TVDBMetadata{}}}, - {name: "TVmaze", metadata: api.ExternalMetadata{TVmaze: &api.TVmazeMetadata{}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - meta := api.PreparedMetadata{ - ExternalMetadata: tt.metadata, - Release: api.ReleaseInfo{Category: "movie"}, - } - if got := resolveCategory(meta); got != "movie" { - t.Fatalf("expected movie fallback, got %q", got) - } - }) - } -} - func hasMISettingsFailure(failures []api.RuleFailure) bool { for _, failure := range failures { if failure.Rule == "require_valid_mi_setting" { @@ -102,13 +99,17 @@ func hasMISettingsFailure(failures []api.RuleFailure) bool { return false } +func encodeAssessments(status api.EncodeSettingsStatus) api.ReleaseAssessments { + return api.ReleaseAssessments{MediaInfoEncodeSettings: status} +} + func TestEvaluateRulesUnit3DEnforcesMediaInfoSettings(t *testing.T) { // RF is a known UNIT3D tracker but its RuleSet does not opt into // RequireValidMISetting; the UNIT3D upload rejects encodes without encoding // settings, so the rule must fire at prep time regardless. - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "movie"}, - ValidMediaInfoSettings: false, + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "movie"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusMissing), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RF", meta) if !hasMISettingsFailure(failures) { @@ -119,23 +120,23 @@ func TestEvaluateRulesUnit3DEnforcesMediaInfoSettings(t *testing.T) { func TestEvaluateRulesUnit3DWithoutRuleSetEnforcesMediaInfoSettings(t *testing.T) { // ACM is a known UNIT3D tracker with no tracker-specific RuleSet. The early // "not found" return must not skip the MediaInfo-settings enforcement. - meta := api.PreparedMetadata{ValidMediaInfoSettings: false} + meta := api.RuleSubject{Assessments: encodeAssessments(api.EncodeSettingsStatusMissing)} failures := evaluateNonMetadataRulesForTest(context.Background(), "ACM", meta) if !hasMISettingsFailure(failures) { t.Fatalf("expected require_valid_mi_setting failure for ACM, got %#v", failures) } - meta.ValidMediaInfoSettings = true + meta.Assessments.MediaInfoEncodeSettings = api.EncodeSettingsStatusPresent failures = evaluateNonMetadataRulesForTest(context.Background(), "ACM", meta) if len(failures) != 0 { t.Fatalf("expected no failures for ACM with valid MI settings, got %#v", failures) } } -func TestEvaluateRulesUnit3DAllowsValidMediaInfoSettings(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "movie"}, - ValidMediaInfoSettings: true, +func TestEvaluateRulesUnit3DAllowsPresentMediaInfoEncodeSettings(t *testing.T) { + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "movie"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RF", meta) if hasMISettingsFailure(failures) { @@ -146,7 +147,7 @@ func TestEvaluateRulesUnit3DAllowsValidMediaInfoSettings(t *testing.T) { func TestEvaluateRulesNonUnit3DOptInMediaInfoSettings(t *testing.T) { // BHD is not a UNIT3D-upload tracker but opts in via its RuleSet, so the // per-tracker flag must still be honored. - meta := api.PreparedMetadata{ValidMediaInfoSettings: false} + meta := api.RuleSubject{Assessments: encodeAssessments(api.EncodeSettingsStatusMissing)} failures := evaluateNonMetadataRulesForTest(context.Background(), "BHD", meta) if !hasMISettingsFailure(failures) { t.Fatalf("expected require_valid_mi_setting failure for BHD, got %#v", failures) @@ -154,10 +155,10 @@ func TestEvaluateRulesNonUnit3DOptInMediaInfoSettings(t *testing.T) { } func TestEvaluateRulesLanguageRulePasses(t *testing.T) { - meta := api.PreparedMetadata{ - AudioLanguages: []string{"french"}, - SubtitleLanguages: nil, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + AudioLanguages: []string{"french"}, + SubtitleLanguages: nil, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "TOS", meta) if len(failures) != 0 { @@ -166,7 +167,7 @@ func TestEvaluateRulesLanguageRulePasses(t *testing.T) { } func TestEvaluateRulesLanguageRuleMissingData(t *testing.T) { - meta := api.PreparedMetadata{ValidMediaInfoSettings: true} + meta := api.RuleSubject{Assessments: encodeAssessments(api.EncodeSettingsStatusPresent)} failures := evaluateNonMetadataRulesForTest(context.Background(), "TOS", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) @@ -174,16 +175,19 @@ func TestEvaluateRulesLanguageRuleMissingData(t *testing.T) { if failures[0].Rule != "language_rule" { t.Fatalf("unexpected rule key: %s", failures[0].Rule) } + if failures[0].Disposition != api.RuleDispositionStrict { + t.Fatalf("language disposition = %q, want strict", failures[0].Disposition) + } } func TestEvaluateRulesLanguageRuleOriginalFallback(t *testing.T) { - meta := api.PreparedMetadata{ - AudioLanguages: []string{"Japanese"}, - SubtitleLanguages: []string{"English"}, - ValidMediaInfoSettings: true, - Container: "mkv", - Release: api.ReleaseInfo{Resolution: "720p"}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + AudioLanguages: []string{"Japanese"}, + SubtitleLanguages: []string{"English"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Container: "mkv", + Release: api.ReleaseInfo{Resolution: "720p"}, + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, }, } @@ -194,32 +198,35 @@ func TestEvaluateRulesLanguageRuleOriginalFallback(t *testing.T) { } func TestEvaluateRulesLUMERequiresMKVForNonDisc(t *testing.T) { - meta := api.PreparedMetadata{ - Container: "mp4", - AudioLanguages: []string{"English"}, - SubtitleLanguages: []string{"English"}, - ValidMediaInfoSettings: true, - Release: api.ReleaseInfo{Resolution: "720p"}, + meta := api.RuleSubject{ + Container: "mp4", + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: "720p"}, } failures := evaluateNonMetadataRulesForTest(context.Background(), "LUME", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) } - if failures[0].Rule != "extra_check" { + if failures[0].Rule != "container" { t.Fatalf("unexpected rule key: %s", failures[0].Rule) } + if failures[0].Disposition != api.RuleDispositionStrict { + t.Fatalf("container disposition = %q, want strict", failures[0].Disposition) + } if failures[0].Reason != "LUME only allows MKV containers for non-disc uploads." { t.Fatalf("unexpected failure reason: %s", failures[0].Reason) } } func TestEvaluateRulesLUMEAllowsMKVForNonDisc(t *testing.T) { - meta := api.PreparedMetadata{ - Container: "mkv", - AudioLanguages: []string{"English"}, - SubtitleLanguages: []string{"English"}, - ValidMediaInfoSettings: true, - Release: api.ReleaseInfo{Resolution: "720p"}, + meta := api.RuleSubject{ + Container: "mkv", + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: "720p"}, } failures := evaluateNonMetadataRulesForTest(context.Background(), "LUME", meta) if len(failures) != 0 { @@ -228,11 +235,11 @@ func TestEvaluateRulesLUMEAllowsMKVForNonDisc(t *testing.T) { } func TestEvaluateRulesLUMESkipsContainerRuleForDisc(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "BDMV", - Container: "mp4", - ValidMediaInfoSettings: true, - Release: api.ReleaseInfo{Resolution: "480p"}, + meta := api.RuleSubject{ + DiscType: "BDMV", + Container: "mp4", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: "480p"}, } failures := evaluateNonMetadataRulesForTest(context.Background(), "LUME", meta) if len(failures) != 0 { @@ -241,7 +248,7 @@ func TestEvaluateRulesLUMESkipsContainerRuleForDisc(t *testing.T) { } func TestEvaluateRulesPTPRequiresMovieForNonPackTV(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "tv"}} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "tv"}} failures := evaluateNonMetadataRulesForTest(context.Background(), "PTP", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) @@ -252,7 +259,7 @@ func TestEvaluateRulesPTPRequiresMovieForNonPackTV(t *testing.T) { } func TestEvaluateRulesPTPAllowsTVPacks(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "tv"}, TVPack: true} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "tv"}, TVPack: true} failures := evaluateNonMetadataRulesForTest(context.Background(), "PTP", meta) if len(failures) != 0 { t.Fatalf("expected no failures, got %#v", failures) @@ -260,7 +267,7 @@ func TestEvaluateRulesPTPAllowsTVPacks(t *testing.T) { } func TestEvaluateRulesANTRequiresMovie(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "tv"}} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "tv"}} failures := evaluateNonMetadataRulesForTest(context.Background(), "ANT", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) @@ -271,7 +278,7 @@ func TestEvaluateRulesANTRequiresMovie(t *testing.T) { } func TestEvaluateRulesANTAllowsMovie(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "movie"}} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "movie"}} failures := evaluateNonMetadataRulesForTest(context.Background(), "ANT", meta) if len(failures) != 0 { t.Fatalf("expected no failures, got %#v", failures) @@ -279,7 +286,7 @@ func TestEvaluateRulesANTAllowsMovie(t *testing.T) { } func TestEvaluateRulesBHDRequiresValidMISettings(t *testing.T) { - meta := api.PreparedMetadata{ValidMediaInfoSettings: false} + meta := api.RuleSubject{Assessments: encodeAssessments(api.EncodeSettingsStatusMissing)} failures := evaluateNonMetadataRulesForTest(context.Background(), "BHD", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) @@ -288,7 +295,7 @@ func TestEvaluateRulesBHDRequiresValidMISettings(t *testing.T) { t.Fatalf("unexpected rule key: %s", failures[0].Rule) } - meta.ValidMediaInfoSettings = true + meta.Assessments.MediaInfoEncodeSettings = api.EncodeSettingsStatusPresent failures = evaluateNonMetadataRulesForTest(context.Background(), "BHD", meta) if len(failures) != 0 { t.Fatalf("expected no failures, got %#v", failures) @@ -298,16 +305,16 @@ func TestEvaluateRulesBHDRequiresValidMISettings(t *testing.T) { func TestEvaluateRulesBHDBlocksAdultContent(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - ValidMediaInfoSettings: true, - ExternalIDs: api.ExternalIDs{Category: "movie", IMDBID: 1234567}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Identity: api.ExternalIdentity{Category: "movie", IMDBID: 1234567}, + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{Keywords: "adult"}, IMDB: &api.IMDBMetadata{IMDBID: 1234567, Title: "Example Release"}, }, } - failures := EvaluateRules(context.Background(), "BHD", meta, nil) + failures := evaluateBHDRulesWithRegistryForTest(context.Background(), meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) } @@ -322,18 +329,18 @@ func TestEvaluateRulesBHDBlocksAdultContent(t *testing.T) { func TestEvaluateRulesBHDIgnoresStaleAdultMetadata(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - SourcePath: "current", - ValidMediaInfoSettings: true, - Release: api.ReleaseInfo{Genre: "Drama"}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + SourcePath: "current", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Genre: "Drama"}, + ProviderMetadata: api.SourceScopedMetadata{ SourcePath: "other", TMDB: &api.TMDBMetadata{Keywords: "adult", Genres: "pornography"}, IMDB: &api.IMDBMetadata{Genres: "xxx"}, }, } - failures := EvaluateRules(context.Background(), "BHD", meta, nil) + failures := evaluateBHDRulesWithRegistryForTest(context.Background(), meta) if hasRuleFailure(failures, "block_adult") { t.Fatalf("expected stale adult metadata to be ignored, got %#v", failures) } @@ -343,16 +350,16 @@ func TestEvaluateRulesBHDBlocksAdultMetadataForExactSourcePath(t *testing.T) { t.Parallel() sourcePath := filepath.Join(t.TempDir(), "Example.Release.2026.1080p-GRP.mkv") - meta := api.PreparedMetadata{ - SourcePath: sourcePath, - ValidMediaInfoSettings: true, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + SourcePath: sourcePath, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + ProviderMetadata: api.SourceScopedMetadata{ SourcePath: sourcePath, TMDB: &api.TMDBMetadata{Keywords: "adult"}, }, } - failures := EvaluateRules(context.Background(), "BHD", meta, nil) + failures := evaluateBHDRulesWithRegistryForTest(context.Background(), meta) if !hasRuleFailure(failures, "block_adult") { t.Fatal("expected exact-source adult metadata to be applied") } @@ -382,18 +389,18 @@ func TestEvaluateRulesBHDIgnoresCaseOnlyDistinctAdultMetadata(t *testing.T) { t.Skip("filesystem does not distinguish case-only paths") } - meta := api.PreparedMetadata{ - SourcePath: currentPath, - ValidMediaInfoSettings: true, - Release: api.ReleaseInfo{Genre: "Drama"}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + SourcePath: currentPath, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Genre: "Drama"}, + ProviderMetadata: api.SourceScopedMetadata{ SourcePath: storedPath, TMDB: &api.TMDBMetadata{Keywords: "adult", Genres: "pornography"}, IMDB: &api.IMDBMetadata{Genres: "xxx"}, }, } - failures := EvaluateRules(context.Background(), "BHD", meta, nil) + failures := evaluateBHDRulesWithRegistryForTest(context.Background(), meta) if hasRuleFailure(failures, "block_adult") { t.Fatal("expected case-only-distinct adult metadata to be ignored") } @@ -402,18 +409,21 @@ func TestEvaluateRulesBHDIgnoresCaseOnlyDistinctAdultMetadata(t *testing.T) { func TestEvaluateRulesBHDRejectsInvalidContainerForUploadTypes(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - ValidMediaInfoSettings: true, - Type: "REMUX", - Container: "avi", + meta := api.RuleSubject{ + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Type: "REMUX", + Container: "avi", } failures := evaluateNonMetadataRulesForTest(context.Background(), "BHD", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure, got %#v", failures) } - if failures[0].Rule != "extra_check" { + if failures[0].Rule != "container" { t.Fatalf("unexpected rule key: %s", failures[0].Rule) } + if failures[0].Disposition != api.RuleDispositionStrict { + t.Fatalf("container disposition = %q, want strict", failures[0].Disposition) + } meta.Container = "mkv" failures = evaluateNonMetadataRulesForTest(context.Background(), "BHD", meta) @@ -427,27 +437,45 @@ func TestEvaluateRulesBLUContainerRules(t *testing.T) { tests := []struct { name string - meta api.PreparedMetadata + meta api.RuleSubject wantBlock bool }{ { - name: "non disc defaults to mkv", - meta: api.PreparedMetadata{Type: "WEBDL", Container: "avi", ValidMediaInfoSettings: true}, + name: "non disc defaults to mkv", + meta: api.RuleSubject{ + Type: "WEBDL", + Container: "avi", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + }, wantBlock: true, }, { - name: "hdtv allows ts", - meta: api.PreparedMetadata{Type: "HDTV", Container: "ts", ValidMediaInfoSettings: true}, + name: "hdtv allows ts", + meta: api.RuleSubject{ + Type: "HDTV", + Container: "ts", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + }, wantBlock: false, }, { - name: "dolby vision webdl allows mp4", - meta: api.PreparedMetadata{Type: "WEBDL", Container: "mp4", WebDV: true, ValidMediaInfoSettings: true}, + name: "dolby vision webdl allows mp4", + meta: api.RuleSubject{ + Type: "WEBDL", + Container: "mp4", + WebDV: true, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + }, wantBlock: false, }, { - name: "disc skips container rule", - meta: api.PreparedMetadata{DiscType: "BDMV", Type: "WEBDL", Container: "avi", ValidMediaInfoSettings: true}, + name: "disc skips container rule", + meta: api.RuleSubject{ + DiscType: "BDMV", + Type: "WEBDL", + Container: "avi", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + }, wantBlock: false, }, } @@ -460,6 +488,9 @@ func TestEvaluateRulesBLUContainerRules(t *testing.T) { if tc.wantBlock && len(failures) == 0 { t.Fatalf("expected BLU container failure") } + if tc.wantBlock && failures[0].Disposition != api.RuleDispositionStrict { + t.Fatalf("container disposition = %q, want strict", failures[0].Disposition) + } if !tc.wantBlock && len(failures) != 0 { t.Fatalf("expected no BLU container failures, got %#v", failures) } @@ -468,7 +499,7 @@ func TestEvaluateRulesBLUContainerRules(t *testing.T) { } func TestEvaluateRulesNBLRequiresTV(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "movie"}} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "movie"}} failures := evaluateNonMetadataRulesForTest(context.Background(), "NBL", meta) if len(failures) != 2 { t.Fatalf("expected 2 failures, got %#v", failures) @@ -479,7 +510,7 @@ func TestEvaluateRulesNBLRequiresTV(t *testing.T) { } func TestEvaluateRulesNBLAllowsTV(t *testing.T) { - meta := api.PreparedMetadata{ExternalIDs: api.ExternalIDs{Category: "tv"}} + meta := api.RuleSubject{Identity: api.ExternalIdentity{Category: "tv"}} failures := evaluateNonMetadataRulesForTest(context.Background(), "NBL", meta) if len(failures) != 1 { t.Fatalf("expected 1 failure because language data is missing, got %#v", failures) @@ -490,12 +521,12 @@ func TestEvaluateRulesNBLAllowsTV(t *testing.T) { } func TestEvaluateRulesNBLAllowsTVWithOriginalAudioAndEnglishSubs(t *testing.T) { - meta := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "tv"}, + meta := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "tv"}, DiscType: "", AudioLanguages: []string{"Japanese"}, SubtitleLanguages: []string{"English"}, - ExternalMetadata: api.ExternalMetadata{ + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, }, } @@ -506,17 +537,17 @@ func TestEvaluateRulesNBLAllowsTVWithOriginalAudioAndEnglishSubs(t *testing.T) { } func TestEvaluateRulesNBLSkipsLanguageRuleForBDMVOnly(t *testing.T) { - bdmv := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "tv"}, - DiscType: "BDMV", + bdmv := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "tv"}, + DiscType: "BDMV", } if failures := evaluateNonMetadataRulesForTest(context.Background(), "NBL", bdmv); len(failures) != 0 { t.Fatalf("expected BDMV to skip NBL language rule, got %#v", failures) } - dvd := api.PreparedMetadata{ - ExternalIDs: api.ExternalIDs{Category: "tv"}, - DiscType: "DVD", + dvd := api.RuleSubject{ + Identity: api.ExternalIdentity{Category: "tv"}, + DiscType: "DVD", } failures := evaluateNonMetadataRulesForTest(context.Background(), "NBL", dvd) if len(failures) != 1 { @@ -530,7 +561,7 @@ func TestEvaluateRulesNBLSkipsLanguageRuleForBDMVOnly(t *testing.T) { func TestEvaluateRulesDPDoesNotSpecialCaseFGTEncodes(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ + meta := api.RuleSubject{ Tag: "-FGT", Type: "ENCODE", AudioLanguages: []string{"English"}, @@ -547,10 +578,10 @@ func TestEvaluateRulesDPDoesNotSpecialCaseFGTEncodes(t *testing.T) { func TestEvaluateRulesRHDRequiresGermanAudio(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - AudioLanguages: []string{"English"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + AudioLanguages: []string{"English"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RHD", meta) if len(failures) != 1 { @@ -564,10 +595,10 @@ func TestEvaluateRulesRHDRequiresGermanAudio(t *testing.T) { func TestEvaluateRulesRHDAllowsGermanAudio(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RHD", meta) if len(failures) != 0 { @@ -578,12 +609,12 @@ func TestEvaluateRulesRHDAllowsGermanAudio(t *testing.T) { func TestEvaluateRulesRHDRequiresGermanAudioForDisc(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - Type: "DISC", - DiscType: "BDMV", - AudioLanguages: []string{"English"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + Type: "DISC", + DiscType: "BDMV", + AudioLanguages: []string{"English"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RHD", meta) if len(failures) != 1 { @@ -597,12 +628,12 @@ func TestEvaluateRulesRHDRequiresGermanAudioForDisc(t *testing.T) { func TestEvaluateRulesRHDAllowsGermanAudioForDisc(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - Type: "DISC", - DiscType: "BDMV", - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + Type: "DISC", + DiscType: "BDMV", + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RHD", meta) if len(failures) != 0 { @@ -613,11 +644,11 @@ func TestEvaluateRulesRHDAllowsGermanAudioForDisc(t *testing.T) { func TestEvaluateRulesRHDRequiresSceneNFO(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - Scene: true, - AudioLanguages: []string{"German"}, - Release: api.ReleaseInfo{Resolution: "1080p"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + Scene: true, + AudioLanguages: []string{"German"}, + Release: api.ReleaseInfo{Resolution: "1080p"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "RHD", meta) if len(failures) != 1 { @@ -631,7 +662,7 @@ func TestEvaluateRulesRHDRequiresSceneNFO(t *testing.T) { func TestEvaluateRulesRHDAllowsNonSceneWithoutNFO(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ + meta := api.RuleSubject{ AudioLanguages: []string{"German"}, Release: api.ReleaseInfo{Resolution: "1080p"}, } @@ -646,10 +677,10 @@ func TestEvaluateRulesRHDAllowsNonSceneWithoutNFO(t *testing.T) { func TestEvaluateRulesTOSRequiresSceneNFO(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{ - Scene: true, - AudioLanguages: []string{"French"}, - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + Scene: true, + AudioLanguages: []string{"French"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "TOS", meta) if len(failures) != 1 { @@ -661,13 +692,15 @@ func TestEvaluateRulesTOSRequiresSceneNFO(t *testing.T) { } func TestEvaluateRulesAitherRequiresLanguageForNonDisc(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "", - ValidMediaInfo: true, - ValidMediaInfoSettings: true, - AudioLanguages: []string{"Japanese"}, - SubtitleLanguages: []string{"German"}, - ExternalMetadata: api.ExternalMetadata{ + meta := api.RuleSubject{ + DiscType: "", + Assessments: api.ReleaseAssessments{ + MediaInfoUniqueID: api.UniqueIDStatusPresent, + MediaInfoEncodeSettings: api.EncodeSettingsStatusPresent, + }, + AudioLanguages: []string{"Japanese"}, + SubtitleLanguages: []string{"German"}, + ProviderMetadata: api.SourceScopedMetadata{ TMDB: &api.TMDBMetadata{OriginalLanguage: "ja"}, }, } @@ -681,9 +714,9 @@ func TestEvaluateRulesAitherRequiresLanguageForNonDisc(t *testing.T) { } func TestEvaluateRulesA4KSkipsLanguageRuleForDisc(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "BDMV", - ValidMediaInfoSettings: true, + meta := api.RuleSubject{ + DiscType: "BDMV", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), } failures := evaluateNonMetadataRulesForTest(context.Background(), "A4K", meta) if len(failures) != 0 { @@ -691,10 +724,27 @@ func TestEvaluateRulesA4KSkipsLanguageRuleForDisc(t *testing.T) { } } +func TestEvaluateRulesTTRLanguageFailuresAreStrict(t *testing.T) { + t.Parallel() + + meta := api.RuleSubject{ + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + Release: api.ReleaseInfo{Language: []string{"English"}}, + } + failures := evaluateNonMetadataRulesForTest(context.Background(), "TTR", meta) + for _, rule := range []string{"language_rule", "spanish_track_required"} { + failure, found := findRuleFailure(failures, rule) + if !found || failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("%s = %#v, failures=%#v", rule, failure, failures) + } + } +} + func TestEvaluateRulesLSTRequiresValidMIAndLanguage(t *testing.T) { - meta := api.PreparedMetadata{ - DiscType: "", - ValidMediaInfoSettings: false, + meta := api.RuleSubject{ + DiscType: "", + Assessments: encodeAssessments(api.EncodeSettingsStatusMissing), } failures := evaluateNonMetadataRulesForTest(context.Background(), "LST", meta) if len(failures) < 2 { @@ -719,14 +769,17 @@ func hasRuleFailure(failures []api.RuleFailure, rule string) bool { func TestEvaluateRulesModifiedReleaseAcrossFamilies(t *testing.T) { t.Parallel() - renamed := api.PreparedMetadata{ + heuristicRename := api.RuleSubject{ SourcePath: "/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP", Release: api.ReleaseInfo{Group: "GRP"}, } - clean := api.PreparedMetadata{ + clean := api.RuleSubject{ SourcePath: "/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP", Release: api.ReleaseInfo{Group: "GRP"}, } + sceneRename := clean + sceneRename.SceneRenamed = true + sceneRename.SceneRenamedReason = "source does not match its original scene release name (renamed or modified)" // Covers a UNIT3D tracker (LST), a non-UNIT3D tracker (PTP), an AZ-family // tracker (PHD), and a tracker with no rule set of its own (HDB) to prove the @@ -734,14 +787,26 @@ func TestEvaluateRulesModifiedReleaseAcrossFamilies(t *testing.T) { for _, tracker := range []string{"LST", "PTP", "PHD", "HDB"} { t.Run(tracker, func(t *testing.T) { t.Parallel() - got := evaluateNonMetadataRulesForTest(context.Background(), tracker, renamed) + got := evaluateNonMetadataRulesForTest(context.Background(), tracker, heuristicRename) failure, ok := findRuleFailure(got, "modified_release") if !ok { t.Fatalf("expected modified_release failure for %s, got %#v", tracker, got) } + if failure.Disposition != api.RuleDispositionWaivable { + t.Fatalf("heuristic modified_release disposition for %s = %q, want waivable", tracker, failure.Disposition) + } if !strings.Contains(failure.Reason, "renamed") { t.Fatalf("expected a meaningful reason mentioning 'renamed' for %s, got %q", tracker, failure.Reason) } + + sceneFailures := evaluateNonMetadataRulesForTest(context.Background(), tracker, sceneRename) + sceneFailure, ok := findRuleFailure(sceneFailures, "modified_release") + if !ok { + t.Fatalf("expected scene modified_release failure for %s, got %#v", tracker, sceneFailures) + } + if sceneFailure.Disposition != api.RuleDispositionStrict { + t.Fatalf("scene modified_release disposition for %s = %q, want strict", tracker, sceneFailure.Disposition) + } if clean := evaluateNonMetadataRulesForTest(context.Background(), tracker, clean); hasRuleFailure(clean, "modified_release") { t.Fatalf("did not expect modified_release failure for clean release on %s, got %#v", tracker, clean) } @@ -755,7 +820,7 @@ func TestEvaluateRulesModifiedReleaseAcrossFamilies(t *testing.T) { func TestEvaluateRulesMetadataPolicyReturnsEvaluatedEmpty(t *testing.T) { t.Parallel() - clean := api.PreparedMetadata{ + clean := api.RuleSubject{ SourcePath: "/data/movies/Example.Movie.2026.2160p.MA.WEB-DL.DDP5.1.HDR.H.265-GRP", Release: api.ReleaseInfo{Group: "GRP"}, } @@ -764,14 +829,233 @@ func TestEvaluateRulesMetadataPolicyReturnsEvaluatedEmpty(t *testing.T) { } } -func TestEvaluateRulesModifiedReleaseExemptsManual(t *testing.T) { +func TestResolutionDependentRulesAreStrict(t *testing.T) { t.Parallel() + base := func(resolution string) api.RuleSubject { + return api.RuleSubject{ + Container: "mkv", + AudioLanguages: []string{"English", "German"}, + SubtitleLanguages: []string{"English"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: resolution}, + } + } + type ruleTest struct { + name string + tracker string + meta api.RuleSubject + rule string + want bool + } + tests := make([]ruleTest, 0, 20) + tests = append(tests, []ruleTest{ + { +name: "RHD missing", + tracker: "RHD", + meta: base(""), + rule: "min_resolution", + want: true, +}, + { +name: "RHD below", + tracker: "RHD", + meta: base("576p"), + rule: "min_resolution", + want: true, +}, + { +name: "RHD boundary", + tracker: "RHD", + meta: base("720p"), + rule: "min_resolution", +}, + { +name: "SP below", + tracker: "SP", + meta: base("720p"), + rule: "min_resolution", + want: true, +}, + { +name: "SP boundary", + tracker: "SP", + meta: base("1080p"), + rule: "min_resolution", +}, + { +name: "LUME missing", + tracker: "LUME", + meta: base(""), + rule: "resolution_required", + want: true, +}, + { +name: "LUME below", + tracker: "LUME", + meta: base("576p"), + rule: "min_resolution", + want: true, +}, + { +name: "LUME boundary", + tracker: "LUME", + meta: base("720p"), + rule: "min_resolution", +}, + { +name: "PHD SD progressive", + tracker: "PHD", + meta: base("576p"), + rule: "sd_forbidden", + want: true, +}, + { +name: "PHD SD interlaced", + tracker: "PHD", + meta: base("480i"), + rule: "sd_forbidden", + want: true, +}, + { +name: "HDS missing", + tracker: "HDS", + meta: base(""), + rule: "min_resolution", + want: true, +}, + { +name: "HDS below", + tracker: "HDS", + meta: base("576p"), + rule: "min_resolution", + want: true, +}, + { +name: "HDS boundary", + tracker: "HDS", + meta: base("720p"), + rule: "min_resolution", +}, + { +name: "HDT missing", + tracker: "HDT", + meta: base(""), + rule: "resolution_required", + want: true, +}, + { +name: "HDT known SD", + tracker: "HDT", + meta: base("576p"), + rule: "resolution_required", +}, + { +name: "TVC UHD", + tracker: "TVC", + meta: base("2160p"), + rule: "uhd_forbidden", + want: true, +}, + { +name: "TVC HD", + tracker: "TVC", + meta: base("1080p"), + rule: "uhd_forbidden", +}, + }...) + + ulcxHEVC := base("1080p") + ulcxHEVC.VideoCodec = "HEVC" + tests = append(tests, ruleTest{ +name: "ULCX HEVC threshold", + tracker: "ULCX", + meta: ulcxHEVC, + rule: "hevc_resolution_2160p", + want: true, +}) + ulcxEncode := base("576p") + ulcxEncode.Type = "ENCODE" + tests = append(tests, ruleTest{ +name: "ULCX encode minimum", + tracker: "ULCX", + meta: ulcxEncode, + rule: "encode_min_resolution", + want: true, +}) + phdCodec := base("2160p") + phdCodec.Type = "ENCODE" + phdCodec.Source = "BLURAY" + phdCodec.VideoEncode = "x264" + tests = append(tests, ruleTest{ +name: "PHD H264 threshold", + tracker: "PHD", + meta: phdCodec, + rule: "h264_resolution_limit", + want: true, +}) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + failures := evaluateNonMetadataRulesForTest(context.Background(), test.tracker, test.meta) + failure, found := findRuleFailure(failures, test.rule) + if found != test.want { + t.Fatalf("%s found=%t, want %t; failures=%#v", test.rule, found, test.want, failures) + } + if found && failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("%s disposition=%q, want strict", test.rule, failure.Disposition) + } + }) + } +} - renamed := api.PreparedMetadata{ - SourcePath: "/data/movies/Example Movie 2026 2160p MA WEB-DL DDP5 1 HDR H 265-GRP", - Release: api.ReleaseInfo{Group: "GRP"}, +func TestCustomRulesReturnMultipleKeyedDispositions(t *testing.T) { + t.Parallel() + meta := api.RuleSubject{ + Container: "mkv", + AudioLanguages: []string{"English"}, + SubtitleLanguages: []string{"English"}, + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: "1080p"}, + VideoCodec: "HEVC", + ProviderMetadata: api.SourceScopedMetadata{TMDB: &api.TMDBMetadata{Keywords: "concert"}}, + } + failures := evaluateNonMetadataRulesForTest(context.Background(), "ULCX", meta) + concert, hasConcert := findRuleFailure(failures, "block_concert") + resolution, hasResolution := findRuleFailure(failures, "hevc_resolution_2160p") + if !hasConcert || !hasResolution || concert.Disposition != api.RuleDispositionWaivable || resolution.Disposition != api.RuleDispositionStrict { + t.Fatalf("custom failures = %#v", failures) + } +} + +func TestTVCNonResolutionUploadRestrictionsAreStrict(t *testing.T) { + t.Parallel() + + meta := api.RuleSubject{ + DiscType: "BDMV", + Type: "REMUX", + Assessments: encodeAssessments(api.EncodeSettingsStatusPresent), + Release: api.ReleaseInfo{Resolution: "1080p"}, + } + failures := evaluateNonMetadataRulesForTest(context.Background(), "TVC", meta) + for _, rule := range []string{"disc_forbidden", "remux_forbidden"} { + failure, found := findRuleFailure(failures, rule) + if !found || failure.Disposition != api.RuleDispositionStrict { + t.Fatalf("%s = %#v, failures=%#v", rule, failure, failures) + } + } +} + +func TestRuleEvaluationCancellationReturnsError(t *testing.T) { + t.Parallel() + registry, err := impl.NewRegistry() + if err != nil { + t.Fatalf("registry: %v", err) } - if got := evaluateNonMetadataRulesForTest(context.Background(), "MANUAL", renamed); hasRuleFailure(got, "modified_release") { - t.Fatalf("expected MANUAL to be exempt from modified_release, got %#v", got) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + failures, err := trackers.EvaluateRulesWithRegistry(ctx, registry, "ULCX", api.RuleSubject{}, api.NopLogger{}) + if err == nil || len(failures) != 0 { + t.Fatalf("canceled evaluation failures=%#v err=%v", failures, err) } } diff --git a/internal/trackers/screenshot_slots.go b/internal/trackers/screenshot_slots.go index b399ab82..9b4c6a00 100644 --- a/internal/trackers/screenshot_slots.go +++ b/internal/trackers/screenshot_slots.go @@ -15,7 +15,7 @@ import ( "strings" internalerrors "github.com/autobrr/upbrr/internal/errors" - "github.com/autobrr/upbrr/internal/services/imagehost" + imagehost "github.com/autobrr/upbrr/internal/imagehosting/host" "github.com/autobrr/upbrr/pkg/api" ) @@ -38,7 +38,11 @@ var ( slotComparisonURL = regexp.MustCompile(`(?i)https?://[^\s\]]+\.(?:png|jpe?g|gif|webp)`) slotURLImgPattern = regexp.MustCompile(`(?is)\[url=(https?://[^\]]+)\]\s*\[img[^\]]*\](.*?)\[/img\]\s*\[/url\]`) slotImgPattern = regexp.MustCompile(`(?is)\[img[^\]]*\](.*?)\[/img\]`) - posterLikeSlotHosts = map[string]struct{}{"image.tmdb.org": {}, "themoviedb.org": {}, "www.themoviedb.org": {}} + posterLikeSlotHosts = map[string]struct{}{ + "image.tmdb.org": {}, + "themoviedb.org": {}, + "www.themoviedb.org": {}, + } ) type parsedDescriptionSlot struct { @@ -49,10 +53,11 @@ type parsedDescriptionSlot struct { func screenshotSlotsFromSource( ctx context.Context, tracker string, - meta api.PreparedMetadata, - repo api.MetadataRepository, + meta api.UploadSubject, + repo UploadPersistence, logger api.Logger, preloaded *preloadedDescriptionAssetData, + registry *Registry, ) ([]api.ScreenshotSlot, error) { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("trackers: load screenshot slots canceled: %w", err) @@ -86,7 +91,7 @@ func screenshotSlotsFromSource( return cloneScreenshotSlots(slots), nil } - slots, err = synthesizeScreenshotSlots(ctx, tracker, meta, repo, logger, preloaded) + slots, err = synthesizeScreenshotSlots(ctx, tracker, meta, repo, logger, preloaded, registry) if err != nil { return nil, err } @@ -101,8 +106,8 @@ func screenshotSlotsFromSource( func filterStoredSlotsForSelectedImages( ctx context.Context, - meta api.PreparedMetadata, - repo api.MetadataRepository, + meta api.UploadSubject, + repo UploadPersistence, slots []api.ScreenshotSlot, preloaded *preloadedDescriptionAssetData, ) ([]api.ScreenshotSlot, error) { @@ -137,8 +142,8 @@ func filterStoredSlotsForSelectedImages( func appendStoredSelectionSlots( ctx context.Context, - meta api.PreparedMetadata, - repo api.MetadataRepository, + meta api.UploadSubject, + repo UploadPersistence, slots []api.ScreenshotSlot, preloaded *preloadedDescriptionAssetData, ) ([]api.ScreenshotSlot, error) { @@ -166,12 +171,13 @@ func selectedPathExists(selectedPaths map[string]struct{}, imagePath string) boo func synthesizeScreenshotSlots( ctx context.Context, tracker string, - meta api.PreparedMetadata, - repo api.MetadataRepository, + meta api.UploadSubject, + repo UploadPersistence, logger api.Logger, preloaded *preloadedDescriptionAssetData, + registry *Registry, ) ([]api.ScreenshotSlot, error) { - description, _, _ := resolveTrackerDescription(ctx, tracker, meta, repo, logger, preloaded) + description, _, _ := resolveTrackerDescription(ctx, tracker, meta, repo, logger, preloaded, registry) selections, err := finalSelectionsFromSource(ctx, meta, repo, preloaded) if err != nil && !errorsIsNotFound(err) { return nil, err @@ -718,12 +724,17 @@ func normalizeSlotOrders(slots []api.ScreenshotSlot) []api.ScreenshotSlot { return slots } +// SlotUploadAttachmentResult contains updated screenshot slots and unmatched uploads. type SlotUploadAttachmentResult struct { MatchedUploads int FallbackMatched int UnmatchedUploads int } +// ApplyUploadedVariantsToSlots mutates slots by upserting host/scope variants. +// Uploads match by local path, then known URL; unmatched uploads use ordered +// fallback only when their count exactly matches remaining renderable pathless +// slots. The result reports upload counts, not variant insertions. func ApplyUploadedVariantsToSlots(slots []api.ScreenshotSlot, uploads []api.UploadedImageLink) SlotUploadAttachmentResult { if len(slots) == 0 || len(uploads) == 0 { return SlotUploadAttachmentResult{} @@ -745,7 +756,13 @@ func ApplyUploadedVariantsToSlots(slots []api.ScreenshotSlot, uploads []api.Uplo result := SlotUploadAttachmentResult{} seenUploads := make(map[string]struct{}, len(uploads)) for _, upload := range uploads { - uploadKey := strings.ToLower(strings.TrimSpace(upload.Host)) + "\x00" + normalizeUsageScope(upload.UsageScope) + "\x00" + strings.TrimSpace(upload.ImagePath) + uploadKey := strings.ToLower( + strings.TrimSpace(upload.Host), + ) + "\x00" + normalizeUsageScope( + upload.UsageScope, + ) + "\x00" + strings.TrimSpace( + upload.ImagePath, + ) if _, exists := seenUploads[uploadKey]; exists { continue } @@ -1035,7 +1052,13 @@ func slotIdentity(slot api.ScreenshotSlot) string { return "unknown" } -func upsertScreenshotVariantsFromUploads(ctx context.Context, repo api.MetadataRepository, sourcePath string, slots []api.ScreenshotSlot, uploads []api.UploadedImageLink) error { +func upsertScreenshotVariantsFromUploads( + ctx context.Context, + repo UploadPersistence, + sourcePath string, + slots []api.ScreenshotSlot, + uploads []api.UploadedImageLink, +) error { if repo == nil || len(slots) == 0 || len(uploads) == 0 { return nil } diff --git a/internal/trackers/service.go b/internal/trackers/service.go index 9f46bff8..ba44830b 100644 --- a/internal/trackers/service.go +++ b/internal/trackers/service.go @@ -13,17 +13,17 @@ import ( "time" "github.com/autobrr/upbrr/internal/config" - internalerrors "github.com/autobrr/upbrr/internal/errors" + "github.com/autobrr/upbrr/internal/description" + "github.com/autobrr/upbrr/internal/logging" "github.com/autobrr/upbrr/internal/redaction" - "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/services/description" "github.com/autobrr/upbrr/pkg/api" ) +// Service coordinates tracker preparation, dry-run rendering, and uploads. type Service struct { cfg config.Config logger api.Logger - repo db.MetadataRepository + repo UploadPersistence images api.ImageHostingService banned *BannedGroupChecker registry *Registry @@ -34,473 +34,34 @@ const ( uploadRecordFinalizationTimeout = 5 * time.Second ) -type trackerUploadResult struct { - tracker string - summary api.UploadSummary - err error - notImplemented bool -} - type imageHostPreflight map[string]descriptionImageHostResolution -func NewService(cfg config.Config, logger api.Logger, repo db.MetadataRepository) *Service { - return NewServiceWithRegistryAndImages(cfg, logger, repo, nil, nil) -} - -func NewServiceWithRegistry(cfg config.Config, logger api.Logger, repo db.MetadataRepository, registry *Registry) *Service { +// NewServiceWithRegistry returns a tracker service backed by registry. +func NewServiceWithRegistry(cfg config.Config, logger api.Logger, repo UploadPersistence, registry *Registry) *Service { return NewServiceWithRegistryAndImages(cfg, logger, repo, registry, nil) } -func NewServiceWithRegistryAndImages(cfg config.Config, logger api.Logger, repo db.MetadataRepository, registry *Registry, images api.ImageHostingService) *Service { +// NewServiceWithRegistryAndImages substitutes a no-op logger and binds the +// registry to tracker, banned-group, and image-host preparation. A nil registry +// is retained so public operations can return their explicit configuration error. +func NewServiceWithRegistryAndImages( + cfg config.Config, + logger api.Logger, + repo UploadPersistence, + registry *Registry, + images api.ImageHostingService, +) *Service { if logger == nil { logger = api.NopLogger{} } - return &Service{cfg: cfg, logger: logger, repo: repo, images: images, banned: NewBannedGroupChecker(cfg.MainSettings.DBPath), registry: registry} -} - -// Upload submits prepared metadata to the resolved tracker set. -// The returned summary includes tracker uploads that completed before a later -// failure or cancellation, and pending upload records are finalized with a -// cleanup context when the caller's context has already been canceled. -// Successful tracker results are treated as terminal for finalization even when -// persisting that terminal status logs a warning. -func (s *Service) Upload(ctx context.Context, meta api.PreparedMetadata) (api.UploadSummary, error) { - select { - case <-ctx.Done(): - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - - trackers := resolveTrackers(s.cfg, meta.Trackers, meta.TrackersRemove) - trackers = filterKnownTrackers(trackers, s.logger) - trackers = filterTrackersByRuleFailures(trackers, meta.TrackerRuleFailures, meta.IgnoreTrackerRuleFailures, s.logger) - trackers = filterTrackersByBlocks(trackers, meta.BlockedTrackers, s.logger) - s.logger.Debugf("trackers: resolved %d trackers", len(trackers)) - if len(trackers) == 0 { - s.logger.Infof("trackers: no trackers configured, skipping upload") - return api.UploadSummary{Uploaded: 0}, nil - } - - group := NormalizeBannedReleaseGroup(meta.Tag) - if group != "" { - if err := s.banned.RefreshDynamic(ctx, s.cfg, trackers, s.logger); err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: banned groups refresh: %w", err) - } - for _, tracker := range trackers { - select { - case <-ctx.Done(): - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - banned, err := s.banned.IsBanned(tracker, group) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: %s banned groups: %w", tracker, err) - } - if banned { - return api.UploadSummary{}, fmt.Errorf("trackers: %s group %s: %w", tracker, group, internalerrors.ErrBannedGroup) - } - } - } - - if s.registry == nil { - if s.repo != nil { - for _, tracker := range trackers { - s.logger.Debugf("trackers: creating pending record for %s", tracker) - status := "pending" - if IsInternalGroup(s.cfg, tracker, meta) { - status = "pending-internal" - if s.logger != nil { - s.logger.Infof("trackers: marked internal tracker=%s tag=%s", tracker, meta.Tag) - } - } - err := s.repo.CreateUploadRecord(ctx, db.UploadRecord{ - Tracker: tracker, - Status: status, - CreatedAt: time.Now().UTC(), - SourcePath: meta.SourcePath, - }) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: record %s: %w", tracker, err) - } - if err := s.repo.UpdateLatestUploadRecordStatus(ctx, meta.SourcePath, tracker, "failed"); err != nil { - s.logger.Warnf("trackers: status update failed tracker=%s status=failed err=%s", tracker, redaction.RedactValue(err.Error(), nil)) - } - } - } - return api.UploadSummary{}, fmt.Errorf("trackers: %s: %w", strings.Join(trackers, ","), internalerrors.ErrNotImplemented) - } - - var finalizePending func(context.Context, string) []string - if s.repo != nil { - type recordStatusState struct { - status string - persisted bool - } - - recordStatuses := make(map[string]recordStatusState, len(trackers)) - var recordMu sync.Mutex - updateRecordStatus := func(updateCtx context.Context, tracker string, status string) error { - trimmedTracker := strings.TrimSpace(tracker) - trimmedStatus := strings.TrimSpace(status) - if trimmedTracker == "" || trimmedStatus == "" { - return nil - } - - recordMu.Lock() - current, ok := recordStatuses[trimmedTracker] - if ok && current.status == trimmedStatus && current.persisted { - recordMu.Unlock() - return nil - } - recordMu.Unlock() - - if err := s.repo.UpdateLatestUploadRecordStatus(updateCtx, meta.SourcePath, trimmedTracker, trimmedStatus); err != nil { - s.logger.Warnf("trackers: status update failed tracker=%s status=%s err=%s", trimmedTracker, trimmedStatus, redaction.RedactValue(err.Error(), nil)) - recordMu.Lock() - recordStatuses[trimmedTracker] = recordStatusState{status: trimmedStatus} - recordMu.Unlock() - return fmt.Errorf("trackers: status update %s (%s): %w", trimmedTracker, trimmedStatus, err) - } - recordMu.Lock() - recordStatuses[trimmedTracker] = recordStatusState{status: trimmedStatus, persisted: true} - recordMu.Unlock() - return nil - } - finalizePending = func(updateCtx context.Context, status string) []string { - recordMu.Lock() - targetStatuses := make(map[string]string, len(recordStatuses)) - for tracker, current := range recordStatuses { - switch { - case current.status == "pending" || current.status == "pending-internal": - targetStatuses[tracker] = status - case !current.persisted: - targetStatuses[tracker] = current.status - } - } - recordMu.Unlock() - - failures := make([]string, 0) - for tracker, targetStatus := range targetStatuses { - if err := updateRecordStatus(updateCtx, tracker, targetStatus); err != nil { - failures = append(failures, fmt.Sprintf("%s (%s): %v", tracker, targetStatus, err)) - } - } - return failures - } - - for _, tracker := range trackers { - select { - case <-ctx.Done(): - cleanupCtx, cleanupCancel := uploadRecordCleanupContext(ctx) - finalizePending(cleanupCtx, "canceled") - cleanupCancel() - return api.UploadSummary{}, fmt.Errorf("context canceled: %w", ctx.Err()) - default: - } - s.logger.Debugf("trackers: creating pending record for %s", tracker) - status := "pending" - if IsInternalGroup(s.cfg, tracker, meta) { - status = "pending-internal" - if s.logger != nil { - s.logger.Infof("trackers: marked internal tracker=%s tag=%s", tracker, meta.Tag) - } - } - err := s.repo.CreateUploadRecord(ctx, db.UploadRecord{ - Tracker: tracker, - Status: status, - CreatedAt: time.Now().UTC(), - SourcePath: meta.SourcePath, - }) - if err != nil { - return api.UploadSummary{}, fmt.Errorf("trackers: record %s: %w", tracker, err) - } - recordMu.Lock() - recordStatuses[strings.TrimSpace(tracker)] = recordStatusState{status: status, persisted: true} - recordMu.Unlock() - } - - preflight := s.preflightDescriptionImageHosts(ctx, meta, trackers) - results, err := s.uploadTrackersConcurrently(ctx, meta, trackers, preflight) - statusCtx, statusCancel := uploadRecordCleanupContext(ctx) - defer statusCancel() - - summary := api.UploadSummary{} - notImplemented := make([]string, 0) - failedTrackers := make([]string, 0) - failedMessages := make([]string, 0) - retryStatusUpdates := false - - for _, result := range results { - if result.notImplemented { - notImplemented = append(notImplemented, result.tracker) - if updateRecordStatus(statusCtx, result.tracker, "failed") != nil { - retryStatusUpdates = true - } - continue - } - if result.err != nil { - if err != nil && isContextCancellation(result.err) { - continue - } - failedTrackers = append(failedTrackers, result.tracker) - failedMessages = append(failedMessages, fmt.Sprintf("%s: %v", result.tracker, result.err)) - if updateRecordStatus(statusCtx, result.tracker, "failed") != nil { - retryStatusUpdates = true - } - continue - } - - if updateRecordStatus(statusCtx, result.tracker, "uploaded") != nil { - retryStatusUpdates = true - } - summary.Uploaded += result.summary.Uploaded - if len(result.summary.UploadedTorrents) > 0 { - summary.UploadedTorrents = append(summary.UploadedTorrents, result.summary.UploadedTorrents...) - } - } - - statusFailures := make([]string, 0) - if err != nil { - statusFailures = append(statusFailures, finalizePending(statusCtx, "canceled")...) - } else if retryStatusUpdates { - statusFailures = append(statusFailures, finalizePending(statusCtx, "failed")...) - } - if len(failedMessages) > 0 { - s.logger.Warnf("trackers: upload failures: %s", strings.Join(failedMessages, "; ")) - } - if len(notImplemented) > 0 { - s.logger.Warnf("trackers: not implemented: %s", strings.Join(notImplemented, ",")) - } - - if summary.Uploaded > 0 { - if err != nil { - return summary, err - } - if len(failedTrackers) > 0 { - return summary, fmt.Errorf("trackers: %s", strings.Join(failedMessages, "; ")) - } - if len(statusFailures) > 0 { - return summary, fmt.Errorf("trackers: status updates: %s", strings.Join(statusFailures, "; ")) - } - return summary, err - } - if err != nil { - return summary, err - } - if len(failedTrackers) > 0 { - return summary, fmt.Errorf("trackers: %s", strings.Join(failedMessages, "; ")) - } - if len(notImplemented) > 0 { - return summary, fmt.Errorf("trackers: %s: %w", strings.Join(notImplemented, ","), internalerrors.ErrNotImplemented) - } - if len(statusFailures) > 0 { - return summary, fmt.Errorf("trackers: status updates: %s", strings.Join(statusFailures, "; ")) - } - - return summary, nil - } - - preflight := s.preflightDescriptionImageHosts(ctx, meta, trackers) - results, err := s.uploadTrackersConcurrently(ctx, meta, trackers, preflight) - - summary := api.UploadSummary{} - notImplemented := make([]string, 0) - failedTrackers := make([]string, 0) - failedMessages := make([]string, 0) - for _, result := range results { - if result.notImplemented { - notImplemented = append(notImplemented, result.tracker) - continue - } - if result.err != nil { - if err != nil && isContextCancellation(result.err) { - continue - } - failedTrackers = append(failedTrackers, result.tracker) - failedMessages = append(failedMessages, fmt.Sprintf("%s: %v", result.tracker, result.err)) - continue - } - summary.Uploaded += result.summary.Uploaded - if len(result.summary.UploadedTorrents) > 0 { - summary.UploadedTorrents = append(summary.UploadedTorrents, result.summary.UploadedTorrents...) - } - } - - if len(failedMessages) > 0 { - s.logger.Warnf("trackers: upload failures: %s", strings.Join(failedMessages, "; ")) - } - if len(notImplemented) > 0 { - s.logger.Warnf("trackers: not implemented: %s", strings.Join(notImplemented, ",")) - } - - if summary.Uploaded > 0 { - if err != nil { - return summary, err - } - if len(failedTrackers) > 0 { - return summary, fmt.Errorf("trackers: %s", strings.Join(failedMessages, "; ")) - } - return summary, err - } - if err != nil { - return summary, err - } - if len(failedTrackers) > 0 { - return summary, fmt.Errorf("trackers: %s", strings.Join(failedMessages, "; ")) + return &Service{ + cfg: cfg, + logger: logger, + repo: repo, + images: images, + banned: NewBannedGroupCheckerWithRegistry(cfg.MainSettings.DBPath, registry), + registry: registry, } - if len(notImplemented) > 0 { - return summary, fmt.Errorf("trackers: %s: %w", strings.Join(notImplemented, ","), internalerrors.ErrNotImplemented) - } - - return summary, nil -} - -func (s *Service) uploadTrackersConcurrently(ctx context.Context, meta api.PreparedMetadata, trackers []string, preflight imageHostPreflight) ([]trackerUploadResult, error) { - workerCount := s.maxConcurrentTrackerUploads(len(trackers)) - results := make([]trackerUploadResult, len(trackers)) - if workerCount <= 0 { - return results, nil - } - - trackerConfigs := make([]config.TrackerConfig, len(trackers)) - trackerMetas := make([]api.PreparedMetadata, len(trackers)) - prepErrors := make([]error, len(trackers)) - for idx, tracker := range trackers { - trackerCfg := trackerConfigFor(s.cfg, tracker) - trackerCfg = applyTrackerConfigOverrides(trackerCfg, meta.TrackerConfigOverrides) - trackerConfigs[idx] = trackerCfg - if _, ok := s.registry.Lookup(tracker); !ok { - continue - } - trackerMeta, err := PrepareTrackerUploadTorrent(meta, s.cfg.MainSettings.DBPath, tracker, trackerCfg) - if err != nil { - prepErrors[idx] = fmt.Errorf("trackers: %s upload torrent artifact: %w", tracker, err) - continue - } - trackerMetas[idx] = trackerMeta - } - - jobs := make(chan int) - var wg sync.WaitGroup - - worker := func() { - defer wg.Done() - for idx := range jobs { - tracker := trackers[idx] - result := trackerUploadResult{tracker: tracker} - - if ctx.Err() != nil { - result.err = ctx.Err() - results[idx] = result - continue - } - - definition, ok := s.registry.Lookup(tracker) - if !ok { - result.notImplemented = true - results[idx] = result - continue - } - - trackerCfg := trackerConfigs[idx] - trackerMeta := trackerMetas[idx] - if prepErrors[idx] != nil { - result.err = prepErrors[idx] - results[idx] = result - continue - } - resolution, ok := preflight[strings.ToUpper(strings.TrimSpace(tracker))] - if !ok { - var err error - resolution, err = ensureDescriptionImageHost(ctx, tracker, trackerMeta, s.cfg, trackerCfg, s.repo, s.images, s.logger) - if err != nil { - s.logger.Warnf("trackers: description image host resolution failed for %s: %v", tracker, err) - result.err = err - results[idx] = result - continue - } - } - if resolution.blocking { - message := strings.TrimSpace(resolution.feedback.Message) - if message == "" { - message = "image-host requirements could not be met" - } - s.logger.Warnf("trackers: skipping %s due to image host failure: %s", tracker, message) - result.err = errors.New(message) - results[idx] = result - continue - } - assets, err := ResolveDescriptionAssets(ctx, tracker, trackerMeta, s.repo, s.logger) - if err != nil { - result.err = err - results[idx] = result - continue - } - applyResolvedDescriptionScreenshots(ctx, trackerMeta, s.repo, nil, &assets, resolution.screenshots) - uploadSummary, err := definition.Upload(ctx, UploadRequest{ - Tracker: tracker, - Meta: trackerMeta, - TrackerConfig: trackerCfg, - AppConfig: s.cfg, - Logger: s.logger, - Repo: s.repo, - Images: s.images, - Assets: &assets, - }) - if err != nil { - if errors.Is(err, internalerrors.ErrNotImplemented) { - result.notImplemented = true - results[idx] = result - continue - } - result.err = err - results[idx] = result - continue - } - - result.summary = uploadSummary - results[idx] = result - } - } - - for range workerCount { - wg.Add(1) - go worker() - } - - for idx := range trackers { - select { - case <-ctx.Done(): - close(jobs) - wg.Wait() - for canceledIdx := idx; canceledIdx < len(trackers); canceledIdx++ { - if results[canceledIdx].tracker == "" { - results[canceledIdx] = trackerUploadResult{tracker: trackers[canceledIdx], err: ctx.Err()} - } - } - return results, fmt.Errorf("context canceled: %w", ctx.Err()) - case jobs <- idx: - } - } - - close(jobs) - wg.Wait() - - if ctx.Err() != nil { - return results, fmt.Errorf("context canceled: %w", ctx.Err()) - } - - return results, nil -} - -func isContextCancellation(err error) bool { - return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) -} - -// uploadRecordCleanupContext keeps terminal upload record updates bounded while -// allowing them to outlive the caller's canceled context. -func uploadRecordCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.WithoutCancel(ctx), uploadRecordFinalizationTimeout) } func (s *Service) maxConcurrentTrackerUploads(total int) int { @@ -520,14 +81,14 @@ func (s *Service) maxConcurrentTrackerUploads(total int) int { // preflightDescriptionImageHosts resolves hosted screenshot URLs before upload // workers run, using configured image-host preferences even for trackers without // a restricted image-host policy. -func (s *Service) preflightDescriptionImageHosts(ctx context.Context, meta api.PreparedMetadata, trackers []string) imageHostPreflight { - preferredImageHosts := preparationImageHostPreferences(s.cfg, meta, trackers, s.logger) +func (s *Service) preflightDescriptionImageHosts(ctx context.Context, meta api.UploadSubject, trackers []string) imageHostPreflight { + preferredImageHosts := preparationImageHostPreferences(s.cfg, meta, trackers, s.logger, s.registry) return s.preflightDescriptionImageHostsWithPreferences(ctx, meta, trackers, preferredImageHosts, nil, true) } func (s *Service) preflightDescriptionImageHostsWithPreferences( ctx context.Context, - meta api.PreparedMetadata, + meta api.UploadSubject, trackers []string, preferredImageHosts map[string]string, preloaded *preloadedDescriptionAssetData, @@ -552,21 +113,22 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( default: } - if _, ok := s.registry.Lookup(tracker); !ok { + mode, ok := s.registry.LookupUploadContentMode(tracker) + if !ok || !mode.UsesImages() { continue } trackerCfg := trackerConfigFor(s.cfg, tracker) trackerCfg = applyTrackerConfigOverrides(trackerCfg, meta.TrackerConfigOverrides) targetKey := "" - policy, err := resolveImageHostPolicyForMetadata(tracker, s.cfg, trackerCfg, meta, meta.ImageHostOverrides) + policy, err := resolveImageHostPolicyForMetadataWithRegistry(s.registry, tracker, s.cfg, trackerCfg, meta.ImageHostOverrides) if err != nil { s.logger.Warnf("trackers: image host preflight failed for %s: %v", tracker, err) continue } if host := preferredHost(effectiveImageHostSelectionPolicy(policy, preferredImageHosts[strings.ToUpper(strings.TrimSpace(tracker))])); host != "" { - targetKey = strings.ToLower(strings.TrimSpace(host)) + "\x00" + normalizeUsageScope(usageScopeForHost(host)) + targetKey = strings.ToLower(strings.TrimSpace(host)) + "\x00" + normalizeUsageScope(usageScopeForHost(s.registry, host)) } else if host := preferredImageHosts[strings.ToUpper(strings.TrimSpace(tracker))]; strings.TrimSpace(host) != "" { - targetKey = strings.ToLower(strings.TrimSpace(host)) + "\x00" + normalizeUsageScope(usageScopeForHost(host)) + targetKey = strings.ToLower(strings.TrimSpace(host)) + "\x00" + normalizeUsageScope(usageScopeForHost(s.registry, host)) } entry := preflightEntry{ tracker: tracker, @@ -583,7 +145,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( if preloaded == nil && (len(representatives) > 0 || resolveAll) { var err error - preloaded, err = preloadDescriptionAssetData(ctx, meta, s.repo) + preloaded, err = preloadScreenshotAssetData(ctx, meta, s.repo, s.registry) if err != nil { s.logger.Warnf("trackers: image host preflight preload failed for %s: %v", meta.SourcePath, err) preloaded = nil @@ -604,7 +166,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( if ctx.Err() != nil { return } - resolution, err := ensureDescriptionImageHostWithData( + resolution, err := ensureDescriptionImageHostWithDataAndRegistry( ctx, entry.tracker, meta, @@ -613,6 +175,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( s.repo, s.images, s.logger, + s.registry, preloadedCopy, preferredImageHosts[strings.ToUpper(strings.TrimSpace(entry.tracker))], ) @@ -635,7 +198,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( } if len(representatives) > 0 && reuploaded { - refreshed, err := preloadDescriptionAssetData(ctx, meta, s.repo) + refreshed, err := preloadScreenshotAssetData(ctx, meta, s.repo, s.registry) if err != nil { s.logger.Warnf("trackers: image host preflight reload failed for %s: %v", meta.SourcePath, err) } else { @@ -648,7 +211,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( if _, ok := resolutions[key]; ok { continue } - resolution, err := ensureDescriptionImageHostWithData( + resolution, err := ensureDescriptionImageHostWithDataAndRegistry( ctx, entry.tracker, meta, @@ -657,6 +220,7 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( s.repo, s.images, s.logger, + s.registry, preloaded, preferredImageHosts[strings.ToUpper(strings.TrimSpace(entry.tracker))], ) @@ -669,7 +233,43 @@ func (s *Service) preflightDescriptionImageHostsWithPreferences( return resolutions } -func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadata, trackersList []string) (api.PreparationPreview, error) { +func (s *Service) preloadUploadContentData( + ctx context.Context, + meta api.UploadSubject, + trackerNames []string, +) (*preloadedDescriptionAssetData, error, error) { + needsImages := false + needsDescription := false + for _, tracker := range trackerNames { + mode, ok := s.registry.LookupUploadContentMode(tracker) + if !ok { + continue + } + needsImages = needsImages || mode.UsesImages() + needsDescription = needsDescription || mode.UsesDescription() + } + if !needsImages { + return nil, nil, nil + } + preloaded, err := preloadScreenshotAssetData(ctx, meta, s.repo, s.registry) + if err != nil { + return nil, err, err + } + if !needsDescription { + return preloaded, nil, nil + } + if err := preloadDescriptionFields(ctx, meta, s.repo, preloaded); err != nil { + return preloaded, nil, err + } + return preloaded, nil, nil +} + +// BuildPreparation resolves tracker descriptions and image requirements without +// tracker submission. It may rehost screenshots and persist reusable slot +// variants; tracker-local content failures are returned in the preview while +// cancellation and invalid top-level selection return errors. +func (s *Service) BuildPreparation(ctx context.Context, subject api.DescriptionSubject, trackersList []string) (api.PreparationPreview, error) { + meta := uploadSubjectForDescription(subject) select { case <-ctx.Done(): return api.PreparationPreview{}, fmt.Errorf("context canceled: %w", ctx.Err()) @@ -678,9 +278,8 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat resolved := trackersList if len(resolved) == 0 { - resolved = ResolveTrackersWithDefaults(s.cfg, meta.Trackers, meta.TrackersRemove, s.logger) + resolved = ResolveTrackersWithDefaultsAndRegistry(s.cfg, meta.Trackers, meta.TrackersRemove, s.logger, s.registry) } - resolved = filterTrackersByRuleFailures(resolved, meta.TrackerRuleFailures, meta.IgnoreTrackerRuleFailures, s.logger) resolved = filterTrackersByBlocks(resolved, meta.BlockedTrackers, s.logger) if len(resolved) == 0 { return api.PreparationPreview{}, errors.New("trackers: no trackers configured") @@ -688,15 +287,36 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat if s.registry == nil { return api.PreparationPreview{}, errors.New("trackers: registry not configured") } + contentFailures := make([]api.TrackerContentFailure, 0) + descriptionTrackers := make([]string, 0, len(resolved)) + for _, tracker := range resolved { + mode, ok := s.registry.LookupUploadContentMode(tracker) + if !ok { + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, errors.New("tracker upload content capability is unavailable")) + contentFailures = append(contentFailures, *failed.Failure) + continue + } + if mode.UsesDescription() { + descriptionTrackers = append(descriptionTrackers, tracker) + } + } + resolved = descriptionTrackers + if len(resolved) == 0 { + return api.PreparationPreview{SourcePath: meta.SourcePath, ContentFailures: contentFailures}, nil + } s.logger.Debugf("trackers: preparation decision=build trackers=%d", len(resolved)) - preloaded, err := preloadDescriptionAssetData(ctx, meta, s.repo) + preloaded, err := preloadDescriptionAssetData(ctx, meta, s.repo, s.registry) if err != nil { s.logger.Warnf("trackers: preparation preload failed source=%s err=%s", meta.SourcePath, redaction.RedactValue(err.Error(), nil)) - preloaded = nil + for _, tracker := range resolved { + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, err) + contentFailures = append(contentFailures, *failed.Failure) + } + return api.PreparationPreview{SourcePath: meta.SourcePath, ContentFailures: contentFailures}, nil } - preferredImageHosts := preparationImageHostPreferences(s.cfg, meta, resolved, s.logger) + preferredImageHosts := preparationImageHostPreferences(s.cfg, meta, resolved, s.logger, s.registry) preflight := s.preflightDescriptionImageHostsWithPreferences(ctx, meta, resolved, preferredImageHosts, preloaded, false) preflightUploaded := false for _, resolution := range preflight { @@ -706,46 +326,21 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat } } if preflightUploaded { - refreshed, reloadErr := preloadDescriptionAssetData(ctx, meta, s.repo) + refreshed, reloadErr := preloadDescriptionAssetData(ctx, meta, s.repo, s.registry) if reloadErr != nil { s.logger.Warnf("trackers: preparation preload reload failed source=%s err=%s", meta.SourcePath, redaction.RedactValue(reloadErr.Error(), nil)) - } else { - preloaded = refreshed + for _, tracker := range resolved { + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, reloadErr) + contentFailures = append(contentFailures, *failed.Failure) + } + return api.PreparationPreview{SourcePath: meta.SourcePath, ContentFailures: contentFailures}, nil } + preloaded = refreshed } grouped := make(map[string]*api.PreparationDescription) order := make([]string, 0, len(resolved)) placeholderCount := 0 - placeholder := func(groupKey, tracker string, note string, imageHost api.ImageHostFeedback) { - key := strings.TrimSpace(groupKey) - if key == "" { - key = tracker - } - entry, exists := grouped[key] - if !exists { - entry = &api.PreparationDescription{ - GroupKey: key, - Trackers: []string{}, - RawDescription: "", - RawDescriptionHTML: "", - Description: "", - DescriptionHTML: "", - ImageHost: imageHost, - } - grouped[key] = entry - order = append(order, key) - } else { - entry.ImageHost = mergePreparationImageHostFeedback(entry.ImageHost, imageHost) - } - entry.Trackers = append(entry.Trackers, tracker) - placeholderCount++ - if note != "" { - s.logger.Warnf("trackers: preparation placeholder tracker=%s note=%s", tracker, note) - } else { - s.logger.Warnf("trackers: preparation placeholder for %s", tracker) - } - } for _, tracker := range resolved { select { @@ -756,12 +351,8 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat definition, ok := s.registry.Lookup(tracker) if !ok { - placeholder("", tracker, "not registered", api.ImageHostFeedback{}) - continue - } - builder, ok := definition.(DescriptionBuilder) - if !ok { - placeholder("", tracker, "no description builder", api.ImageHostFeedback{}) + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, errors.New("tracker is not registered")) + contentFailures = append(contentFailures, *failed.Failure) continue } trackerCfg := trackerConfigFor(s.cfg, tracker) @@ -770,7 +361,7 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat resolution, ok := preflight[key] if !ok { var err error - resolution, err = ensureDescriptionImageHostWithData( + resolution, err = ensureDescriptionImageHostWithDataAndRegistry( ctx, tracker, meta, @@ -779,40 +370,45 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat s.repo, s.images, s.logger, + s.registry, preloaded, preferredImageHosts[key], ) if err != nil { s.logger.Warnf("trackers: preparation image host resolution failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) - placeholder("", tracker, fmt.Sprintf("image host error: %v", err), api.ImageHostFeedback{}) + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, err) + contentFailures = append(contentFailures, *failed.Failure) continue } } if resolution.blocking { - feedback := blockedPreparationImageHostFeedback(resolution.feedback) - placeholder(preparationBlockedImageHostGroupKey(tracker), tracker, "image host blocked", feedback) + message := strings.TrimSpace(resolution.feedback.Message) + if message == "" { + message = "image-host requirements could not be met" + } + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, errors.New(message)) + contentFailures = append(contentFailures, *failed.Failure) continue } assets, err := resolveDescriptionAssets(ctx, tracker, meta, s.repo, s.logger, preloaded) if err != nil { s.logger.Warnf("trackers: preparation assets failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) - assets = DescriptionAssets{} + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, err) + contentFailures = append(contentFailures, *failed.Failure) + continue } applyResolvedDescriptionScreenshots(ctx, meta, s.repo, preloaded, &assets, resolution.screenshots) - result, err := builder.BuildDescription(ctx, DescriptionRequest{ - Tracker: tracker, - Meta: meta, - TrackerConfig: trackerCfg, - AppConfig: s.cfg, - Logger: s.logger, - Repo: s.repo, - Assets: &assets, - }) - if err != nil { - s.logger.Errorf("trackers: preparation failed for %s: %v", tracker, err) - placeholder("", tracker, "build error", api.ImageHostFeedback{}) + plan, failure := definition.Prepare(ctx, s.preparationInput(ctx, PreparationIntentDescriptionPreview, tracker, meta, trackerCfg, &assets)) + if failure != nil { + s.logger.Errorf("trackers: preparation failed for %s: %v", tracker, failure) + failed := failedPreparedUploadContent(tracker, UploadContentModeDescription, failure) + contentFailures = append(contentFailures, *failed.Failure) continue } + result := plan.Description() + if err := plan.Release(); err != nil { + s.logger.Warnf("trackers: plan release failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) + } descriptionText := strings.TrimSpace(result.Description) if descriptionText == "" { @@ -859,16 +455,59 @@ func (s *Service) BuildPreparation(ctx context.Context, meta api.PreparedMetadat results = stabilizePreparationDescriptionGroupKeys(results) - return api.PreparationPreview{SourcePath: meta.SourcePath, Descriptions: results}, nil + return api.PreparationPreview{ + SourcePath: meta.SourcePath, + Descriptions: results, + ContentFailures: contentFailures, + }, nil +} + +// uploadSubjectForDescription is private tracker implementation plumbing. +// Callers provide only the description-owned contract. +func uploadSubjectForDescription(subject api.DescriptionSubject) api.UploadSubject { + return api.UploadSubject{ + SourcePath: subject.SourcePath, + DiscType: subject.DiscType, + MediaInfoTextPath: subject.MediaInfoTextPath, + DVDVOBMediaInfoText: subject.DVDVOBMediaInfoText, + DescriptionTemplate: subject.DescriptionTemplate, + EpisodeOverview: subject.EpisodeOverview, + Options: subject.Options, + Release: subject.Release, + SelectedBDMVPlaylists: append([]api.PlaylistInfo(nil), subject.SelectedBDMVPlaylists...), + Tag: subject.Tag, + Identity: subject.Identity, + ProviderMetadata: subject.ProviderMetadata, + SeasonInt: subject.SeasonInt, + EpisodeInt: subject.EpisodeInt, + Filename: subject.Filename, + ReleaseName: subject.ReleaseName, + ReleaseNameNoTag: subject.ReleaseNameNoTag, + ServiceLongName: subject.ServiceLongName, + Type: subject.Type, + HDR: subject.HDR, + ArrReleaseGroup: subject.ArrReleaseGroup, + Trackers: append([]string(nil), subject.Trackers...), + TrackerConfigOverrides: subject.TrackerConfig, + TrackerSiteOverrides: subject.TrackerSite, + ImageHostOverrides: subject.ImageHost, + TrackerData: append([]api.TrackerMetadata(nil), subject.TrackerData...), + } } // preparationImageHostPreferences returns the first upload host each tracker // should prefer when generated screenshots need hosted URLs. -func preparationImageHostPreferences(appCfg config.Config, meta api.PreparedMetadata, trackers []string, logger api.Logger) map[string]string { +func preparationImageHostPreferences( + appCfg config.Config, + meta api.UploadSubject, + trackers []string, + logger api.Logger, + registry *Registry, +) map[string]string { if meta.ImageHostOverrides.PreferredHost != nil { return nil } - targets, err := NeededImageUploadTargetsForMetadata(appCfg, trackers, "", meta) + targets, err := NeededImageUploadTargetsForMetadataWithRegistry(registry, appCfg, trackers, "", meta) if err != nil { if logger != nil { logger.Warnf("trackers: preparation image host target resolution failed: %v", err) @@ -908,20 +547,30 @@ func blockedPreparationImageHostFeedback(feedback api.ImageHostFeedback) api.Ima return feedback } -func preparationBlockedImageHostGroupKey(tracker string) string { - name := strings.ToLower(strings.TrimSpace(tracker)) - if name == "" { - name = "tracker" - } - return name + "|blocked|image-host" +// BuildUploadReview builds tracker payloads needed for upload authorization +// without enabling explicit dry-run diagnostic artifacts or tracker submission. +// Shared preparation may still write local torrent artifacts, rehost images, +// and persist reusable image-slot state. +func (s *Service) BuildUploadReview(ctx context.Context, meta api.UploadSubject, trackersList []string) ([]api.TrackerDryRunEntry, error) { + return s.buildUploadPreview(ctx, meta, trackersList, PreparationIntentUploadReview) +} + +// BuildUploadDryRun builds explicit payload previews without tracker submission. +// Shared preparation may still write local torrent artifacts, rehost images, +// and persist reusable image-slot state. +func (s *Service) BuildUploadDryRun(ctx context.Context, meta api.UploadSubject, trackersList []string) ([]api.TrackerDryRunEntry, error) { + return s.buildUploadPreview(ctx, meta, trackersList, PreparationIntentDryRun) } -// BuildUploadDryRun builds tracker upload payload previews without performing -// tracker uploads. Dry-run processing still evaluates all resolved tracker -// builders and banned-group state. It does not apply rule-failure or blocked -// tracker suppression itself; callers that want suppression must pass an already -// filtered tracker list. -func (s *Service) BuildUploadDryRun(ctx context.Context, meta api.PreparedMetadata, trackersList []string) ([]api.TrackerDryRunEntry, error) { +// buildUploadPreview evaluates every resolved tracker builder and banned-group +// state. Core owns cross-tracker rule, duplicate, and eligibility decisions. +func (s *Service) buildUploadPreview( + ctx context.Context, + meta api.UploadSubject, + trackersList []string, + intent PreparationIntent, +) ([]api.TrackerDryRunEntry, error) { + logger := logging.FromContext(ctx, s.logger) select { case <-ctx.Done(): return nil, fmt.Errorf("context canceled: %w", ctx.Err()) @@ -930,9 +579,9 @@ func (s *Service) BuildUploadDryRun(ctx context.Context, meta api.PreparedMetada resolved := trackersList if len(resolved) == 0 { - resolved = ResolveTrackersWithDefaults(s.cfg, meta.Trackers, meta.TrackersRemove, s.logger) + resolved = ResolveTrackersWithDefaultsAndRegistry(s.cfg, meta.Trackers, meta.TrackersRemove, logger, s.registry) } - resolved = filterKnownTrackers(resolved, s.logger) + resolved = filterKnownTrackersWithRegistry(resolved, logger, s.registry) if len(resolved) == 0 { return nil, errors.New("trackers: no trackers configured") } @@ -940,13 +589,30 @@ func (s *Service) BuildUploadDryRun(ctx context.Context, meta api.PreparedMetada return nil, errors.New("trackers: registry not configured") } - s.logger.Debugf("trackers: dry-run decision=build trackers=%d", len(resolved)) + logger.Debugf("trackers: payload preview decision=build intent=%s trackers=%d", intent, len(resolved)) bannedResults := s.dryRunBannedGroupResults(ctx, meta, resolved) - preloaded, err := preloadDescriptionAssetData(ctx, meta, s.repo) - if err != nil { - s.logger.Warnf("trackers: dry-run preload failed source=%s err=%s", meta.SourcePath, redaction.RedactValue(err.Error(), nil)) - preloaded = nil + preloaded, screenshotPreloadErr, descriptionPreloadErr := s.preloadUploadContentData(ctx, meta, resolved) + if screenshotPreloadErr != nil { + logger.Warnf( + "trackers: payload preview screenshot preload failed intent=%s source=%s err=%s", + intent, + meta.SourcePath, + redaction.RedactValue(screenshotPreloadErr.Error(), nil), + ) + } + if descriptionPreloadErr != nil && !errors.Is(descriptionPreloadErr, screenshotPreloadErr) { + logger.Warnf( + "trackers: payload preview description preload failed intent=%s source=%s err=%s", + intent, + meta.SourcePath, + redaction.RedactValue(descriptionPreloadErr.Error(), nil), + ) + } + preflight := imageHostPreflight(nil) + if screenshotPreloadErr == nil { + preferredImageHosts := preparationImageHostPreferences(s.cfg, meta, resolved, logger, s.registry) + preflight = s.preflightDescriptionImageHostsWithPreferences(ctx, meta, resolved, preferredImageHosts, preloaded, false) } results := make([]api.TrackerDryRunEntry, 0, len(resolved)) @@ -966,75 +632,59 @@ func (s *Service) BuildUploadDryRun(ctx context.Context, meta api.PreparedMetada results = append(results, entry) continue } - builder, ok := definition.(UploadDryRunBuilder) - if !ok { - entry.Status = "not_supported" - entry.Message = "dry-run payload preview not implemented" - applyDryRunBannedGroupResult(&entry, bannedResults) - results = append(results, entry) - continue - } - trackerCfg := trackerConfigFor(s.cfg, tracker) trackerCfg = applyTrackerConfigOverrides(trackerCfg, meta.TrackerConfigOverrides) - trackerMeta, err := PrepareTrackerUploadTorrent(meta, s.cfg.MainSettings.DBPath, tracker, trackerCfg) + trackerMeta, err := PrepareTrackerUploadTorrentWithRegistry(meta, s.cfg.MainSettings.DBPath, tracker, trackerCfg, s.registry) if err != nil { entry.Status = "error" - entry.Message = err.Error() + entry.Message = safeTrackerMessage(err) applyDryRunBannedGroupResult(&entry, bannedResults) results = append(results, entry) continue } - resolution, err := ensureDescriptionImageHostWithData(ctx, tracker, trackerMeta, s.cfg, trackerCfg, s.repo, s.images, s.logger, preloaded) - if err != nil { - s.logger.Warnf("trackers: dry-run image host resolution failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) - entry.Status = "error" - entry.Message = err.Error() - applyDryRunBannedGroupResult(&entry, bannedResults) - results = append(results, entry) - continue + mode, _ := s.registry.LookupUploadContentMode(tracker) + contentPreloadErr := screenshotPreloadErr + if contentPreloadErr == nil && mode.UsesDescription() { + contentPreloadErr = descriptionPreloadErr } - if resolution.blocking { + if contentPreloadErr != nil && mode.UsesImages() { + content := failedPreparedUploadContent(tracker, mode, contentPreloadErr) entry.Status = "blocked" - entry.Message = resolution.feedback.Message - entry.ImageHost = resolution.feedback + entry.Message = content.Failure.Message + entry.ContentFailure = content.Failure applyDryRunBannedGroupResult(&entry, bannedResults) results = append(results, entry) continue } - assets, err := resolveDescriptionAssets(ctx, tracker, trackerMeta, s.repo, s.logger, preloaded) - if err != nil { - entry.Status = "error" - entry.Message = err.Error() + content := s.prepareUploadContent(ctx, tracker, trackerMeta, trackerCfg, preloaded, preflight) + if content.State == preparedUploadContentFailed { + entry.Status = "blocked" + entry.Message = content.Failure.Message + entry.ContentFailure = content.Failure + entry.ImageHost = content.ImageHost applyDryRunBannedGroupResult(&entry, bannedResults) results = append(results, entry) continue } - applyResolvedDescriptionScreenshots(ctx, trackerMeta, s.repo, preloaded, &assets, resolution.screenshots) - preview, err := builder.BuildUploadDryRun(ctx, UploadRequest{ - Tracker: tracker, - Meta: trackerMeta, - TrackerConfig: trackerCfg, - AppConfig: s.cfg, - Logger: s.logger, - Repo: s.repo, - Images: s.images, - Assets: &assets, - }) - if err != nil { + plan, failure := definition.Prepare(ctx, s.preparationInput(ctx, intent, tracker, trackerMeta, trackerCfg, content.Assets)) + if failure != nil { entry.Status = "error" - entry.Message = err.Error() + entry.Message = failure.Message() applyDryRunBannedGroupResult(&entry, bannedResults) results = append(results, entry) continue } + preview := plan.DryRun() + if err := plan.Release(); err != nil { + logger.Warnf("trackers: plan release failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) + } if strings.TrimSpace(preview.Tracker) == "" { preview.Tracker = tracker } if strings.TrimSpace(preview.Status) == "" { preview.Status = "ready" } - preview.ImageHost = resolution.feedback + preview.ImageHost = content.ImageHost applyDryRunBannedGroupResult(&preview, bannedResults) results = append(results, preview) } @@ -1051,29 +701,23 @@ type dryRunBannedGroupResult struct { // dryRunBannedGroupResults refreshes and checks banned groups for dry-run // annotations. A banned group marks the entry but does not suppress payload // generation; refresh or cache errors are reported per tracker for diagnostics. -func (s *Service) dryRunBannedGroupResults(ctx context.Context, meta api.PreparedMetadata, trackers []string) map[string]dryRunBannedGroupResult { +func (s *Service) dryRunBannedGroupResults(ctx context.Context, meta api.UploadSubject, trackers []string) map[string]dryRunBannedGroupResult { + logger := logging.FromContext(ctx, s.logger) group := NormalizeBannedReleaseGroup(meta.Tag) if group == "" || len(trackers) == 0 || s.banned == nil { return nil } results := make(map[string]dryRunBannedGroupResult, len(trackers)) - if err := s.banned.RefreshDynamic(ctx, s.cfg, trackers, s.logger); err != nil { - message := "banned group check failed: " + redaction.RedactValue(err.Error(), nil) - for _, tracker := range trackers { - name := strings.ToUpper(strings.TrimSpace(tracker)) - if name != "" { - results[name] = dryRunBannedGroupResult{err: message} - } - } - return results - } - for _, tracker := range trackers { name := strings.ToUpper(strings.TrimSpace(tracker)) if name == "" { continue } + if err := s.banned.RefreshDynamic(ctx, s.cfg, []string{name}, logger); err != nil { + results[name] = dryRunBannedGroupResult{err: "banned group check failed: " + redaction.RedactValue(err.Error(), nil)} + continue + } banned, err := s.banned.IsBanned(name, group) if err != nil { results[name] = dryRunBannedGroupResult{err: "banned group check failed: " + redaction.RedactValue(err.Error(), nil)} @@ -1115,32 +759,6 @@ func applyDryRunBannedGroupResult(entry *api.TrackerDryRunEntry, results map[str entry.BannedCheckError = result.err } -func filterTrackersByRuleFailures(trackers []string, failures map[string][]api.RuleFailure, ignore bool, logger api.Logger) []string { - if ignore || len(trackers) == 0 || len(failures) == 0 { - return trackers - } - - filtered := make([]string, 0, len(trackers)) - for _, tracker := range trackers { - name := strings.ToUpper(strings.TrimSpace(tracker)) - trackerFailures, ok := failures[name] - if ok && api.HasBlockingRuleFailures(trackerFailures) { - if logger != nil { - for _, failure := range trackerFailures { - if !api.IsBlockingRuleFailure(failure) { - continue - } - logger.Warnf("trackers: skipping %s due to rule failure %s (%s)", name, failure.Rule, failure.Reason) - } - } - continue - } - filtered = append(filtered, tracker) - } - - return filtered -} - func filterTrackersByBlocks(trackers []string, blocked map[string][]api.TrackerBlockReason, logger api.Logger) []string { if len(trackers) == 0 || len(blocked) == 0 { return trackers @@ -1206,7 +824,7 @@ func applyTrackerConfigOverrides(cfg config.TrackerConfig, overrides api.Tracker return cfg } -func filterKnownTrackers(trackers []string, logger api.Logger) []string { +func filterKnownTrackersWithRegistry(trackers []string, logger api.Logger, registry *Registry) []string { if len(trackers) == 0 { return trackers } @@ -1217,7 +835,8 @@ func filterKnownTrackers(trackers []string, logger api.Logger) []string { if upper == "" { continue } - if !IsKnownTracker(upper) { + _, registered := registry.LookupDescriptor(upper) + if !registered { if logger != nil { logger.Infof("trackers: unknown tracker %q, skipping", tracker) } @@ -1321,7 +940,12 @@ func preparationGroupKey(group string, host string, usageScope string) string { return trimmedGroup + "|" + trimmedHost + "|" + trimmedScope } -func preparationDescriptionGroup(grouped map[string]*api.PreparationDescription, order *[]string, groupKey string, candidate api.PreparationDescription) *api.PreparationDescription { +func preparationDescriptionGroup( + grouped map[string]*api.PreparationDescription, + order *[]string, + groupKey string, + candidate api.PreparationDescription, +) *api.PreparationDescription { baseKey := strings.TrimSpace(groupKey) if baseKey == "" { baseKey = "description" @@ -1348,7 +972,12 @@ func preparationDescriptionGroup(grouped map[string]*api.PreparationDescription, } } -func matchingUnit3DPreparationDescriptionGroup(grouped map[string]*api.PreparationDescription, order []string, groupKey string, candidate api.PreparationDescription) *api.PreparationDescription { +func matchingUnit3DPreparationDescriptionGroup( + grouped map[string]*api.PreparationDescription, + order []string, + groupKey string, + candidate api.PreparationDescription, +) *api.PreparationDescription { if preparationDescriptionBaseGroup(groupKey) != "unit3d" { return nil } @@ -1373,7 +1002,12 @@ func preparationDescriptionBaseGroup(groupKey string) string { return baseGroup } -func addPreparationDescriptionGroup(grouped map[string]*api.PreparationDescription, order *[]string, groupKey string, candidate api.PreparationDescription) *api.PreparationDescription { +func addPreparationDescriptionGroup( + grouped map[string]*api.PreparationDescription, + order *[]string, + groupKey string, + candidate api.PreparationDescription, +) *api.PreparationDescription { entry := candidate entry.GroupKey = groupKey entry.Trackers = []string{} diff --git a/internal/trackers/service_plan_test.go b/internal/trackers/service_plan_test.go new file mode 100644 index 00000000..4cbd2863 --- /dev/null +++ b/internal/trackers/service_plan_test.go @@ -0,0 +1,245 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/pkg/api" +) + +type barrierPlanDefinition struct { + name string + started chan<- string + releasePrep <-chan struct{} + submitted chan<- string +} + +func (d barrierPlanDefinition) Name() string { return d.name } + +func (barrierPlanDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (barrierPlanDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } + +func (d barrierPlanDefinition) Prepare(ctx context.Context, _ PreparationInput) (TrackerPlan, *PreparationFailure) { + d.started <- d.name + select { + case <-d.releasePrep: + case <-ctx.Done(): + return TrackerPlan{}, NewPreparationFailure(d.name, "canceled", "preparation canceled", ctx.Err()) + } + return NewUploadPlan(d.name, api.TrackerDryRunEntry{Tracker: d.name, Status: "ready"}, func(context.Context) (api.UploadSummary, error) { + d.submitted <- d.name + return api.UploadSummary{Uploaded: 1}, nil + }, nil), nil +} + +func TestUploadPreparationHasBoundedPoolAndFullBarrier(t *testing.T) { + t.Parallel() + names := []string{"AITHER", "BHD", "BLU", "HDB", "MTV", "PTP"} + started := make(chan string, len(names)) + submitted := make(chan string, len(names)) + releasePrep := make(chan struct{}) + registry := NewRegistry() + for _, name := range names { + if err := registry.Register(barrierPlanDefinition{ + name: name, + started: started, + releasePrep: releasePrep, + submitted: submitted, + }); err != nil { + t.Fatalf("register %s: %v", name, err) + } + } + svc := NewServiceWithRegistry(config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList(names)}}, nil, nil, registry) + done := make(chan error, 1) + go func() { + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "Example.Release.2026"}) + if err == nil && summary.Uploaded != len(names) { + err = fmt.Errorf("uploaded=%d", summary.Uploaded) + } + done <- err + }() + + for range defaultMaxConcurrentTrackerPreparations { + select { + case <-started: + case <-time.After(10 * time.Second): + t.Fatal("preparation pool did not fill") + } + } + select { + case tracker := <-started: + t.Fatalf("preparation exceeded bound: %s", tracker) + case tracker := <-submitted: + t.Fatalf("submission crossed preparation barrier: %s", tracker) + default: + } + close(releasePrep) + for range names { + select { + case <-submitted: + case <-time.After(10 * time.Second): + t.Fatal("concurrent submission did not finish") + } + } + if err := <-done; err != nil { + t.Fatalf("upload: %v", err) + } +} + +type readyReleasePlanDefinition struct { + name string + releases *atomic.Int32 + submitted *atomic.Int32 + prepared chan<- struct{} +} + +func (d readyReleasePlanDefinition) Name() string { return d.name } + +func (readyReleasePlanDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (readyReleasePlanDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } + +func (d readyReleasePlanDefinition) Prepare(context.Context, PreparationInput) (TrackerPlan, *PreparationFailure) { + if d.prepared != nil { + d.prepared <- struct{}{} + } + return NewUploadPlan(d.name, api.TrackerDryRunEntry{}, func(context.Context) (api.UploadSummary, error) { + d.submitted.Add(1) + return api.UploadSummary{Uploaded: 1}, nil + }, func() error { + d.releases.Add(1) + return nil + }), nil +} + +type canceledPreparationDefinition struct { + name string + started chan<- struct{} + wait <-chan struct{} +} + +func (d canceledPreparationDefinition) Name() string { return d.name } + +func (canceledPreparationDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (canceledPreparationDefinition) DefaultBaseURL() string { + return "https://tracker.example.invalid" +} + +func (d canceledPreparationDefinition) Prepare(ctx context.Context, _ PreparationInput) (TrackerPlan, *PreparationFailure) { + if d.wait != nil { + <-d.wait + } + d.started <- struct{}{} + <-ctx.Done() + return TrackerPlan{}, NewPreparationFailure(d.name, "canceled", "preparation canceled", ctx.Err()) +} + +func TestUploadCancellationBeforeBarrierSubmitsNoneAndReleasesReadyPlans(t *testing.T) { + t.Parallel() + var releases atomic.Int32 + var submits atomic.Int32 + started := make(chan struct{}, 1) + ready := make(chan struct{}, 1) + registry := NewRegistry() + if err := registry.Register(readyReleasePlanDefinition{ + name: "AITHER", + releases: &releases, + submitted: &submits, + prepared: ready, + }); err != nil { + t.Fatalf("register ready: %v", err) + } + if err := registry.Register(canceledPreparationDefinition{ + name: "BLU", + started: started, + wait: ready, + }); err != nil { + t.Fatalf("register blocking: %v", err) + } + svc := NewServiceWithRegistry(config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER", "BLU"}}}, nil, nil, registry) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "Example.Release.2026"}) + done <- err + }() + select { + case <-started: + case <-time.After(10 * time.Second): + t.Fatal("blocking preparation did not start") + } + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("upload error = %v", err) + } + if submits.Load() != 0 || releases.Load() != 1 { + t.Fatalf("submits=%d releases=%d", submits.Load(), releases.Load()) + } +} + +type warningLogger struct { + api.NopLogger + mu sync.Mutex + warnings []string +} + +func (l *warningLogger) Warnf(format string, args ...any) { + l.mu.Lock() + l.warnings = append(l.warnings, fmt.Sprintf(format, args...)) + l.mu.Unlock() +} + +func TestUploadReleaseFailureAfterRemoteSuccessIsSanitizedWarningOnly(t *testing.T) { + t.Parallel() + logger := &warningLogger{} + registry := NewRegistry() + definition := readyReleasePlanDefinition{ + name: "AITHER", + releases: &atomic.Int32{}, + submitted: &atomic.Int32{}, + } + if err := registry.Register(releaseErrorDefinition{readyReleasePlanDefinition: definition}); err != nil { + t.Fatalf("register: %v", err) + } + svc := NewServiceWithRegistry(config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}}, logger, nil, registry) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "Example.Release.2026"}) + if err != nil || summary.Uploaded != 1 { + t.Fatalf("upload = %#v, %v", summary, err) + } + logger.mu.Lock() + joined := strings.Join(logger.warnings, "\n") + logger.mu.Unlock() + if !strings.Contains(joined, "plan release failed") || strings.Contains(joined, "secret-value") { + t.Fatalf("warnings = %q", joined) + } +} + +type releaseErrorDefinition struct{ readyReleasePlanDefinition } + +func (d releaseErrorDefinition) Prepare(context.Context, PreparationInput) (TrackerPlan, *PreparationFailure) { + return NewUploadPlan(d.name, api.TrackerDryRunEntry{}, func(context.Context) (api.UploadSummary, error) { + d.submitted.Add(1) + return api.UploadSummary{Uploaded: 1}, nil + }, func() error { + d.releases.Add(1) + return errors.New("token=secret-value cleanup failed") + }), nil +} diff --git a/internal/trackers/service_test.go b/internal/trackers/service_test.go index 6d24bc75..681cb754 100644 --- a/internal/trackers/service_test.go +++ b/internal/trackers/service_test.go @@ -24,17 +24,16 @@ import ( "github.com/autobrr/upbrr/internal/config" internalerrors "github.com/autobrr/upbrr/internal/errors" - "github.com/autobrr/upbrr/internal/paths" + paths "github.com/autobrr/upbrr/internal/pathing/layout" dbsvc "github.com/autobrr/upbrr/internal/services/db" - descriptionhdb "github.com/autobrr/upbrr/internal/services/description/hdb" "github.com/autobrr/upbrr/pkg/api" ) func TestUploadNoTrackers(t *testing.T) { t.Parallel() - svc := NewService(config.Config{}, nil, nil) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + svc := NewServiceWithRegistry(config.Config{}, nil, nil, NewRegistry()) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -47,10 +46,13 @@ func TestUploadConfiguredTrackers(t *testing.T) { t.Parallel() cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"blU", "bhd"}}} - svc := NewService(cfg, nil, nil) - _, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) - if !errors.Is(err, internalerrors.ErrNotImplemented) { - t.Fatalf("expected not implemented error, got %v", err) + svc := NewServiceWithRegistry(cfg, nil, nil, NewRegistry()) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Uploaded != 0 { + t.Fatalf("expected unsupported configured trackers to remain inert, got %d uploads", summary.Uploaded) } } @@ -58,17 +60,20 @@ func TestUploadOverrideTrackers(t *testing.T) { t.Parallel() cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU"}}} - svc := NewService(cfg, nil, nil) - _, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file", Trackers: []string{"aither"}}) - if !errors.Is(err, internalerrors.ErrNotImplemented) { - t.Fatalf("expected not implemented error, got %v", err) + svc := NewServiceWithRegistry(cfg, nil, nil, NewRegistry()) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file", Trackers: []string{"aither"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Uploaded != 0 { + t.Fatalf("expected unsupported override tracker to remain inert, got %d uploads", summary.Uploaded) } } func TestUploadOverrideTrackersReplaceDefaults(t *testing.T) { t.Parallel() - requests := make(chan UploadRequest, 2) + requests := make(chan PreparationInput, 2) registry := NewRegistry() for _, definition := range []Definition{ trackingUploadDefinition{name: "BLU", requests: requests}, @@ -81,7 +86,7 @@ func TestUploadOverrideTrackersReplaceDefaults(t *testing.T) { cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU"}}} svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{ + summary, err := svc.Upload(context.Background(), api.UploadSubject{ SourcePath: "/tmp/file", Trackers: []string{"aither"}, }) @@ -111,10 +116,13 @@ func TestUploadRemovesTrackers(t *testing.T) { t.Parallel() cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"BLU", "BHD"}}} - svc := NewService(cfg, nil, nil) - _, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file", TrackersRemove: []string{"bhd"}}) - if !errors.Is(err, internalerrors.ErrNotImplemented) { - t.Fatalf("expected not implemented error, got %v", err) + svc := NewServiceWithRegistry(cfg, nil, nil, NewRegistry()) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file", TrackersRemove: []string{"bhd"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Uploaded != 0 { + t.Fatalf("expected unsupported configured trackers to remain inert, got %d uploads", summary.Uploaded) } } @@ -122,8 +130,8 @@ func TestUploadUnknownTrackers(t *testing.T) { t.Parallel() cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"NOPE"}}} - svc := NewService(cfg, nil, nil) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + svc := NewServiceWithRegistry(cfg, nil, nil, NewRegistry()) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -136,8 +144,16 @@ func TestUploadBannedGroup(t *testing.T) { t.Parallel() cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"TOS"}}} - svc := NewService(cfg, nil, nil) - _, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file", Tag: "-FL3ER"}) + registry := NewRegistry() + if err := registry.RegisterDescriptor(Descriptor{ + Name: "TOS", + Definition: trackingUploadDefinition{name: "TOS"}, + BannedGroups: []string{"FL3ER"}, + }); err != nil { + t.Fatalf("register TOS: %v", err) + } + svc := NewServiceWithRegistry(cfg, nil, nil, registry) + _, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file", Tag: "-FL3ER"}) if !errors.Is(err, internalerrors.ErrBannedGroup) { t.Fatalf("expected banned group error, got %v", err) } @@ -154,7 +170,12 @@ func TestUploadSkipsDynamicBannedRefreshForEmptyEffectiveGroup(t *testing.T) { defer server.Close() registry := NewRegistry() - if err := registry.Register(trackingUploadDefinition{name: "AITHER"}); err != nil { + if err := registry.RegisterDescriptor(Descriptor{ + Name: "AITHER", + BaseURL: "https://aither.cc", + Definition: trackingUploadDefinition{name: "AITHER"}, + BannedPolicy: &BannedGroupPolicy{EndpointPath: "/api/blacklists/releasegroups", RequireAPIKey: true}, + }); err != nil { t.Fatalf("register stub: %v", err) } cfg := config.Config{ @@ -162,14 +183,15 @@ func TestUploadSkipsDynamicBannedRefreshForEmptyEffectiveGroup(t *testing.T) { Trackers: config.TrackersConfig{ DefaultTrackers: config.CSVList{"AITHER"}, Trackers: map[string]config.TrackerConfig{ - "AITHER": {APIKey: "aither-key", URL: server.URL}, + "AITHER": {APIKey: "aither-key"}, }, }, } svc := NewServiceWithRegistry(cfg, nil, nil, registry) + svc.banned.client = bannedRewriteClient(t, server) for _, tag := range []string{"-", " - ", " "} { - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file", Tag: tag}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file", Tag: tag}) if err != nil { t.Fatalf("upload tag %q: %v", tag, err) } @@ -181,7 +203,7 @@ func TestUploadSkipsDynamicBannedRefreshForEmptyEffectiveGroup(t *testing.T) { t.Fatalf("expected no dynamic banned refresh for empty effective group, got %d request(s)", got) } - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file", Tag: "-GRP"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file", Tag: "-GRP"}) if err != nil { t.Fatalf("upload real group: %v", err) } @@ -228,10 +250,16 @@ func TestNormalizeTrackersDedup(t *testing.T) { } type stubDryRunDefinition struct { - name string - dryRunErr error + name string + mode UploadContentMode + dryRunErr error + bannedGroups []string + intents *[]PreparationIntent + inputs *[]PreparationInput } +func (s stubDryRunDefinition) BannedGroups() []string { return s.bannedGroups } + type stubUploadArtifactDefinition struct { name string } @@ -245,7 +273,7 @@ type stubPreparationDefinition struct { type trackingUploadDefinition struct { name string started chan<- string - requests chan<- UploadRequest + requests chan<- PreparationInput release <-chan struct{} } @@ -254,6 +282,7 @@ type blockingImageService struct { started chan<- string release <-chan struct{} calls []string + repo *stubRepo } type blockingUploadDefinition struct { @@ -273,11 +302,93 @@ type cancelAfterUploadDefinition struct { type testHDBPreparationDefinition struct{} +func (s stubDryRunDefinition) UploadContentMode() UploadContentMode { + if s.mode != "" { + return s.mode + } + return UploadContentModeDescription +} + +func (stubUploadArtifactDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (stubPreparationDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (trackingUploadDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (blockingUploadDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (cancelAfterUploadDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (testHDBPreparationDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + +func (stubDryRunDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (stubUploadArtifactDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (stubPreparationDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (trackingUploadDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (blockingUploadDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (cancelAfterUploadDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } +func (hostAwareDescriptionDefinition) DefaultBaseURL() string { + return "https://tracker.example.invalid" +} +func (testHDBPreparationDefinition) DefaultBaseURL() string { return "https://tracker.example.invalid" } + +func (s stubDryRunDefinition) TrackerFamily() Family { return testTrackerFamily(s.name) } +func (s stubUploadArtifactDefinition) TrackerFamily() Family { return testTrackerFamily(s.name) } +func (s stubPreparationDefinition) TrackerFamily() Family { return testTrackerFamily(s.name) } +func (t trackingUploadDefinition) TrackerFamily() Family { return testTrackerFamily(t.name) } +func (b blockingUploadDefinition) TrackerFamily() Family { return testTrackerFamily(b.name) } +func (d cancelAfterUploadDefinition) TrackerFamily() Family { return testTrackerFamily(d.name) } +func (s hostAwareDescriptionDefinition) TrackerFamily() Family { + return testTrackerFamily(s.name) +} +func (testHDBPreparationDefinition) TrackerFamily() Family { return FamilyStandalone } + +func (s stubDryRunDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(s.name) +} +func (s stubUploadArtifactDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(s.name) +} +func (s stubPreparationDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(s.name) +} +func (t trackingUploadDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(t.name) +} +func (b blockingUploadDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(b.name) +} +func (d cancelAfterUploadDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(d.name) +} +func (s hostAwareDescriptionDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker(s.name) +} +func (testHDBPreparationDefinition) ImageHostPolicy() *ImageHostPolicy { + return testImageHostPolicyForTracker("HDB") +} + func (b blockingUploadDefinition) Name() string { return b.name } -func (b blockingUploadDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (b blockingUploadDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, b) +} + +func (b blockingUploadDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { if b.started != nil { b.started <- b.name } @@ -294,7 +405,12 @@ func (d cancelAfterUploadDefinition) Name() string { return d.name } -func (d cancelAfterUploadDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (d cancelAfterUploadDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, d) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (d cancelAfterUploadDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { if d.cancel != nil { d.cancel() } @@ -384,7 +500,12 @@ func (s stubUploadArtifactDefinition) Name() string { return s.name } -func (s stubUploadArtifactDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (s stubUploadArtifactDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, s) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (s stubUploadArtifactDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{ Uploaded: 1, UploadedTorrents: []api.UploadedTorrent{ @@ -402,19 +523,25 @@ func (s stubPreparationDefinition) Name() string { return s.name } -func (s stubPreparationDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (s stubPreparationDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, s) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (s stubPreparationDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{Uploaded: 1}, nil } -func (s stubPreparationDefinition) BuildDescription(context.Context, DescriptionRequest) (DescriptionResult, error) { +//nolint:unparam // Error is required by the adapter description callback contract. +func (s stubPreparationDefinition) prepareDescription(context.Context, PreparationInput) (DescriptionResult, error) { return DescriptionResult{Group: s.group, Description: s.description}, nil } -func (s *blockingImageService) ListCandidates(context.Context, api.PreparedMetadata) ([]api.ScreenshotImage, error) { +func (s *blockingImageService) ListCandidates(context.Context, api.ImageHostingSubject) ([]api.ScreenshotImage, error) { return nil, nil } -func (s *blockingImageService) Upload(ctx context.Context, meta api.PreparedMetadata, host string, usageScope string, images []api.ScreenshotImage) ([]api.UploadedImageLink, error) { +func (s *blockingImageService) Upload(ctx context.Context, meta api.ImageHostingSubject, host string, usageScope string, images []api.ScreenshotImage) ([]api.UploadedImageLink, error) { s.mu.Lock() s.calls = append(s.calls, host) s.mu.Unlock() @@ -444,6 +571,11 @@ func (s *blockingImageService) Upload(ctx context.Context, meta api.PreparedMeta WebURL: fmt.Sprintf("https://%s/%d", host, idx), }) } + if s.repo != nil { + s.repo.mu.Lock() + s.repo.uploads = append(s.repo.uploads, results...) + s.repo.mu.Unlock() + } return results, nil } @@ -458,15 +590,25 @@ type hostAwareDescriptionDefinition struct { group string } +func (hostAwareDescriptionDefinition) UploadContentMode() UploadContentMode { + return UploadContentModeDescription +} + func (s hostAwareDescriptionDefinition) Name() string { return s.name } -func (s hostAwareDescriptionDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (s hostAwareDescriptionDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, s) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (s hostAwareDescriptionDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{Uploaded: 1}, nil } -func (s hostAwareDescriptionDefinition) BuildDescription(_ context.Context, req DescriptionRequest) (DescriptionResult, error) { +//nolint:unparam // Error is required by the adapter description callback contract. +func (s hostAwareDescriptionDefinition) prepareDescription(_ context.Context, req PreparationInput) (DescriptionResult, error) { host := "none" if req.Assets != nil && len(req.Assets.Screenshots) > 0 { host = strings.ToLower(strings.TrimSpace(req.Assets.Screenshots[0].Host)) @@ -485,27 +627,54 @@ func (testHDBPreparationDefinition) Name() string { return "HDB" } -func (testHDBPreparationDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (d testHDBPreparationDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, d) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (testHDBPreparationDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{Uploaded: 1}, nil } -func (testHDBPreparationDefinition) BuildDescription(ctx context.Context, req DescriptionRequest) (DescriptionResult, error) { +//nolint:unparam // Error is required by the adapter description callback contract. +func (testHDBPreparationDefinition) prepareDescription(_ context.Context, req PreparationInput) (DescriptionResult, error) { assets := DescriptionAssets{} if req.Assets != nil { assets = *req.Assets } - description, err := descriptionhdb.BuildDescription(ctx, req.Meta, req.AppConfig, assets.Description, assets.MenuImages, assets.Screenshots) - if err != nil { - return DescriptionResult{}, fmt.Errorf("trackers: %w", err) + var description strings.Builder + description.WriteString(assets.Description) + for _, image := range assets.Screenshots { + if strings.TrimSpace(image.RawURL) != "" { + description.WriteString("\n[img]") + description.WriteString(image.RawURL) + description.WriteString("[/img]") + } } - return DescriptionResult{Group: "hdb", Description: description}, nil + return DescriptionResult{Group: "hdb", Description: description.String()}, nil } func (t trackingUploadDefinition) Name() string { return t.name } -func (t trackingUploadDefinition) Upload(_ context.Context, req UploadRequest) (api.UploadSummary, error) { +func (t trackingUploadDefinition) UploadArtifactPolicy() *UploadArtifactPolicy { + switch t.name { + case "HDB": + return &UploadArtifactPolicy{Source: "HDBits"} + case "PTP": + return &UploadArtifactPolicy{Source: "PTP"} + default: + return nil + } +} + +func (t trackingUploadDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + return prepareTestDefinition(ctx, input, t) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (t trackingUploadDefinition) submit(_ context.Context, req PreparationInput) (api.UploadSummary, error) { if t.started != nil { t.started <- t.name } @@ -522,11 +691,22 @@ func (s stubDryRunDefinition) Name() string { return s.name } -func (s stubDryRunDefinition) Upload(context.Context, UploadRequest) (api.UploadSummary, error) { +func (s stubDryRunDefinition) Prepare(ctx context.Context, input PreparationInput) (TrackerPlan, *PreparationFailure) { + if s.intents != nil { + *s.intents = append(*s.intents, input.Intent) + } + if s.inputs != nil { + *s.inputs = append(*s.inputs, input) + } + return prepareTestDefinition(ctx, input, s) +} + +//nolint:unparam // Error is required by the adapter submission callback contract. +func (s stubDryRunDefinition) submit(context.Context, PreparationInput) (api.UploadSummary, error) { return api.UploadSummary{Uploaded: 1}, nil } -func (s stubDryRunDefinition) BuildUploadDryRun(context.Context, UploadRequest) (api.TrackerDryRunEntry, error) { +func (s stubDryRunDefinition) prepareDryRun(context.Context, PreparationInput) (api.TrackerDryRunEntry, error) { if s.dryRunErr != nil { return api.TrackerDryRunEntry{}, s.dryRunErr } @@ -546,7 +726,7 @@ func TestBuildUploadDryRunUsesBuilder(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}, []string{"BLU"}) + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}, []string{"BLU"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -561,6 +741,216 @@ func TestBuildUploadDryRunUsesBuilder(t *testing.T) { } } +func TestBuildUploadPreviewUsesTypedReviewAndDryRunIntents(t *testing.T) { + t.Parallel() + + intents := make([]PreparationIntent, 0, 2) + registry := NewRegistry() + if err := registry.Register(stubDryRunDefinition{name: "BLU", intents: &intents}); err != nil { + t.Fatalf("register stub: %v", err) + } + svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) + subject := api.UploadSubject{SourcePath: "/tmp/file"} + if _, err := svc.BuildUploadReview(context.Background(), subject, []string{"BLU"}); err != nil { + t.Fatalf("build upload review: %v", err) + } + if _, err := svc.BuildUploadDryRun(context.Background(), subject, []string{"BLU"}); err != nil { + t.Fatalf("build explicit dry run: %v", err) + } + if !slices.Equal(intents, []PreparationIntent{PreparationIntentUploadReview, PreparationIntentDryRun}) { + t.Fatalf("preparation intents = %#v", intents) + } +} + +func TestBuildPreparationSkipsAllNonDescriptionContent(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + for _, definition := range []Definition{ + stubDryRunDefinition{name: "NONE", mode: UploadContentModeNone}, + stubDryRunDefinition{name: "IMAGES", mode: UploadContentModeScreenshots}, + } { + if err := registry.Register(definition); err != nil { + t.Fatalf("register stub: %v", err) + } + } + repo := &stubRepo{ + trackerRecordsErr: errors.New("tracker metadata unavailable"), + selectionsErr: errors.New("selections unavailable"), + screenshotSlotsErr: errors.New("slots unavailable"), + uploadsErr: errors.New("uploads unavailable"), + descriptionOverrideErr: errors.New("overrides unavailable"), + } + svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) + + preview, err := svc.BuildPreparation( + context.Background(), + api.NewDescriptionSubject(api.UploadSubject{SourcePath: "/tmp/source"}), + []string{"NONE", "IMAGES"}, + ) + if err != nil { + t.Fatalf("build preparation: %v", err) + } + if len(preview.Descriptions) != 0 || len(preview.ContentFailures) != 0 { + t.Fatalf("expected empty non-description preview, got %#v", preview) + } + if repo.trackerRecordsCalls != 0 || repo.selectionsCalls != 0 || repo.screenshotSlotCalls != 0 || repo.uploadsCalls != 0 || repo.overrideCalls != 0 { + t.Fatalf("expected no content repository reads, got tracker=%d selections=%d slots=%d uploads=%d overrides=%d", + repo.trackerRecordsCalls, + repo.selectionsCalls, + repo.screenshotSlotCalls, + repo.uploadsCalls, + repo.overrideCalls, + ) + } +} + +func TestBuildUploadDryRunNoneModeSkipsSharedContent(t *testing.T) { + t.Parallel() + + inputs := make([]PreparationInput, 0, 1) + registry := NewRegistry() + if err := registry.Register(stubDryRunDefinition{ +name: "NONE", + mode: UploadContentModeNone, + inputs: &inputs, +}); err != nil { + t.Fatalf("register stub: %v", err) + } + repo := &stubRepo{ + trackerRecordsErr: errors.New("tracker metadata unavailable"), + selectionsErr: errors.New("selections unavailable"), + screenshotSlotsErr: errors.New("slots unavailable"), + uploadsErr: errors.New("uploads unavailable"), + descriptionOverrideErr: errors.New("overrides unavailable"), + } + svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) + + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}, []string{"NONE"}) + if err != nil { + t.Fatalf("build dry run: %v", err) + } + if len(entries) != 1 || entries[0].Status != "ready" { + t.Fatalf("expected ready none-mode preview, got %#v", entries) + } + if len(inputs) != 1 || inputs[0].Assets != nil { + t.Fatalf("expected adapter invocation without shared assets, got %#v", inputs) + } + if repo.trackerRecordsCalls != 0 || repo.selectionsCalls != 0 || repo.screenshotSlotCalls != 0 || repo.uploadsCalls != 0 || repo.overrideCalls != 0 { + t.Fatalf("expected no content repository reads, got tracker=%d selections=%d slots=%d uploads=%d overrides=%d", + repo.trackerRecordsCalls, + repo.selectionsCalls, + repo.screenshotSlotCalls, + repo.uploadsCalls, + repo.overrideCalls, + ) + } +} + +func TestBuildUploadDryRunDistinguishesReadyEmptyAndFailedScreenshots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repo *stubRepo + wantStatus string + wantCalls int + }{ + { +name: "ready empty", + repo: &stubRepo{}, + wantStatus: "ready", + wantCalls: 1, +}, + { +name: "failed", + repo: &stubRepo{selectionsErr: errors.New("selections unavailable")}, + wantStatus: "blocked", + wantCalls: 0, +}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + inputs := make([]PreparationInput, 0, 1) + registry := NewRegistry() + if err := registry.Register(stubDryRunDefinition{ +name: "IMAGES", + mode: UploadContentModeScreenshots, + inputs: &inputs, +}); err != nil { + t.Fatalf("register stub: %v", err) + } + svc := NewServiceWithRegistry(config.Config{}, nil, test.repo, registry) + + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}, []string{"IMAGES"}) + if err != nil { + t.Fatalf("build dry run: %v", err) + } + if len(entries) != 1 || entries[0].Status != test.wantStatus { + t.Fatalf("expected status %q, got %#v", test.wantStatus, entries) + } + if len(inputs) != test.wantCalls { + t.Fatalf("adapter calls = %d, want %d", len(inputs), test.wantCalls) + } + if test.wantStatus == "ready" { + if inputs[0].Assets == nil || len(inputs[0].Assets.Screenshots) != 0 || strings.TrimSpace(inputs[0].Assets.Description) != "" { + t.Fatalf("expected ready empty screenshot assets, got %#v", inputs[0].Assets) + } + if test.repo.overrideCalls != 0 { + t.Fatalf("screenshot mode loaded description overrides %d time(s)", test.repo.overrideCalls) + } + } else if entries[0].ContentFailure == nil || entries[0].ContentFailure.Code != api.TrackerEligibilityScreenshotPreparationFailed { + t.Fatalf("expected structured screenshot failure, got %#v", entries[0].ContentFailure) + } + }) + } +} + +func TestBuildUploadDryRunScopesDescriptionPreloadFailure(t *testing.T) { + t.Parallel() + + screenshotInputs := make([]PreparationInput, 0, 1) + descriptionInputs := make([]PreparationInput, 0, 1) + registry := NewRegistry() + for _, definition := range []Definition{ + stubDryRunDefinition{ +name: "IMAGES", + mode: UploadContentModeScreenshots, + inputs: &screenshotInputs, +}, + stubDryRunDefinition{ +name: "DESCRIPTION", + mode: UploadContentModeDescription, + inputs: &descriptionInputs, +}, + } { + if err := registry.Register(definition); err != nil { + t.Fatalf("register stub: %v", err) + } + } + repo := &stubRepo{descriptionOverrideErr: errors.New("description overrides unavailable")} + svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) + + entries, err := svc.BuildUploadDryRun( + context.Background(), + api.UploadSubject{SourcePath: "/tmp/source"}, + []string{"IMAGES", "DESCRIPTION"}, + ) + if err != nil { + t.Fatalf("build dry run: %v", err) + } + if len(entries) != 2 || entries[0].Tracker != "IMAGES" || entries[0].Status != "ready" || entries[1].Tracker != "DESCRIPTION" || entries[1].Status != "blocked" { + t.Fatalf("unexpected mixed-mode results: %#v", entries) + } + if len(screenshotInputs) != 1 || len(descriptionInputs) != 0 { + t.Fatalf("expected only screenshot adapter invocation, screenshots=%d descriptions=%d", len(screenshotInputs), len(descriptionInputs)) + } + if entries[1].ContentFailure == nil || entries[1].ContentFailure.Code != api.TrackerEligibilityDescriptionPreparationFailed { + t.Fatalf("expected structured description failure, got %#v", entries[1].ContentFailure) + } +} + func TestBuildUploadDryRunBlocksWhenImageHostFallbacksFail(t *testing.T) { t.Parallel() @@ -571,8 +961,16 @@ func TestBuildUploadDryRunBlocksWhenImageHostFallbacksFail(t *testing.T) { repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, } images := &stubImageService{ @@ -582,7 +980,7 @@ func TestBuildUploadDryRunBlocksWhenImageHostFallbacksFail(t *testing.T) { } svc := NewServiceWithRegistryAndImages(config.Config{}, nil, repo, registry, images) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"PTP"}) + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}, []string{"PTP"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -593,8 +991,11 @@ func TestBuildUploadDryRunBlocksWhenImageHostFallbacksFail(t *testing.T) { if entry.Status != "blocked" { t.Fatalf("expected blocked dry run, got %#v", entry) } - if entry.ImageHost.Status != "warning" || len(entry.ImageHost.Warnings) != 1 { - t.Fatalf("expected image host warnings to be attached, got %#v", entry.ImageHost) + if entry.ImageHost.Status != "blocked" || len(entry.ImageHost.Warnings) != 1 { + t.Fatalf("expected blocked image host feedback, got %#v", entry.ImageHost) + } + if entry.ContentFailure == nil || entry.ContentFailure.Code != api.TrackerEligibilityDescriptionPreparationFailed { + t.Fatalf("expected structured description failure, got %#v", entry.ContentFailure) } } @@ -602,14 +1003,26 @@ func TestBuildPreparationBlocksWhenImageHostFallbacksFail(t *testing.T) { t.Parallel() registry := NewRegistry() - if err := registry.Register(stubPreparationDefinition{name: "PTP", group: "ptp", description: "saveable description"}); err != nil { + if err := registry.Register(stubPreparationDefinition{ + name: "PTP", + group: "ptp", + description: "saveable description", + }); err != nil { t.Fatalf("register stub: %v", err) } repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, } images := &stubImageService{ @@ -619,31 +1032,22 @@ func TestBuildPreparationBlocksWhenImageHostFallbacksFail(t *testing.T) { } svc := NewServiceWithRegistryAndImages(config.Config{}, nil, repo, registry, images) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"PTP"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: "/tmp/source"}), []string{"PTP"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(preview.Descriptions) != 1 { - t.Fatalf("expected 1 blocked placeholder, got %d", len(preview.Descriptions)) - } - entry := preview.Descriptions[0] - if entry.GroupKey != "ptp|blocked|image-host" { - t.Fatalf("expected blocked image-host group key, got %q", entry.GroupKey) - } - if got := strings.Join(entry.Trackers, ","); got != "PTP" { - t.Fatalf("expected PTP tracker to remain visible, got %q", got) + if len(preview.Descriptions) != 0 { + t.Fatalf("expected no saveable description, got %d", len(preview.Descriptions)) } - if entry.Description != "" || entry.RawDescription != "" || entry.DescriptionHTML != "" || entry.RawDescriptionHTML != "" { - t.Fatalf("expected no saveable prepared description, got %#v", entry) + if len(preview.ContentFailures) != 1 { + t.Fatalf("expected 1 structured content failure, got %#v", preview.ContentFailures) } - if entry.ImageHost.Status != "blocked" { - t.Fatalf("expected blocked image host status, got %#v", entry.ImageHost) + failure := preview.ContentFailures[0] + if failure.Tracker != "PTP" || failure.Code != api.TrackerEligibilityDescriptionPreparationFailed { + t.Fatalf("expected PTP description failure, got %#v", failure) } - if !strings.Contains(entry.ImageHost.Message, "could not upload screenshots") { - t.Fatalf("expected blocking image host message, got %q", entry.ImageHost.Message) - } - if len(entry.ImageHost.Warnings) != 1 || entry.ImageHost.Warnings[0].Host != "pixhost" { - t.Fatalf("expected pixhost failure warning, got %#v", entry.ImageHost.Warnings) + if !strings.Contains(failure.Message, "could not upload screenshots") { + t.Fatalf("expected blocking image host message, got %q", failure.Message) } if got := strings.Join(images.calls, ","); got != "pixhost" { t.Fatalf("expected one preflight upload attempt, got %q", got) @@ -654,7 +1058,11 @@ func TestBuildPreparationBlocksWhenUploadedImagesDoNotCoverRHDSlots(t *testing.T t.Parallel() registry := NewRegistry() - if err := registry.Register(stubPreparationDefinition{name: "RHD", group: "rhd", description: "saveable description"}); err != nil { + if err := registry.Register(stubPreparationDefinition{ + name: "RHD", + group: "rhd", + description: "saveable description", + }); err != nil { t.Fatalf("register stub: %v", err) } @@ -693,28 +1101,22 @@ func TestBuildPreparationBlocksWhenUploadedImagesDoNotCoverRHDSlots(t *testing.T } svc := NewServiceWithRegistryAndImages(cfg, nil, repo, registry, images) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: sourcePath}, []string{"RHD"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: sourcePath}), []string{"RHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(preview.Descriptions) != 1 { - t.Fatalf("expected 1 blocked placeholder, got %d", len(preview.Descriptions)) - } - entry := preview.Descriptions[0] - if entry.GroupKey != "rhd|blocked|image-host" { - t.Fatalf("expected blocked image-host group key, got %q", entry.GroupKey) + if len(preview.Descriptions) != 0 { + t.Fatalf("expected no saveable description, got %d", len(preview.Descriptions)) } - if entry.Description != "" || entry.RawDescription != "" || entry.DescriptionHTML != "" || entry.RawDescriptionHTML != "" { - t.Fatalf("expected no saveable prepared description, got %#v", entry) + if len(preview.ContentFailures) != 1 { + t.Fatalf("expected 1 structured content failure, got %#v", preview.ContentFailures) } - if entry.ImageHost.Status != "blocked" { - t.Fatalf("expected blocked image host status, got %#v", entry.ImageHost) + failure := preview.ContentFailures[0] + if failure.Tracker != "RHD" || failure.Code != api.TrackerEligibilityDescriptionPreparationFailed { + t.Fatalf("expected RHD description failure, got %#v", failure) } - if !strings.Contains(entry.ImageHost.Message, "could not upload screenshots") { - t.Fatalf("expected blocking image host message, got %q", entry.ImageHost.Message) - } - if len(entry.ImageHost.Warnings) != 1 || !strings.Contains(entry.ImageHost.Warnings[0].Message, "missing screenshot variant for slot 0") { - t.Fatalf("expected slot coverage warning, got %#v", entry.ImageHost.Warnings) + if !strings.Contains(failure.Message, "missing screenshot variant for slot 0") { + t.Fatalf("expected slot coverage failure, got %#v", failure) } if got := strings.Join(images.calls, ","); got != "imgbb" { t.Fatalf("expected one imgbb upload attempt, got %q", got) @@ -730,7 +1132,7 @@ func TestBuildUploadDryRunIncludesRuleFailedTrackers(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{ + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{ SourcePath: "/tmp/file", TrackerRuleFailures: map[string][]api.RuleFailure{ "BLU": {{Rule: "require_movie_only", Reason: "movie only"}}, @@ -747,7 +1149,7 @@ func TestBuildUploadDryRunIncludesRuleFailedTrackers(t *testing.T) { } } -func TestBuildUploadDryRunNoBuilderMarkedUnsupported(t *testing.T) { +func TestBuildUploadDryRunUsesRequiredPreparationContract(t *testing.T) { t.Parallel() registry := NewRegistry() @@ -756,15 +1158,15 @@ func TestBuildUploadDryRunNoBuilderMarkedUnsupported(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}, []string{"BLU"}) + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}, []string{"BLU"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } - if entries[0].Status != "not_supported" { - t.Fatalf("expected not_supported status, got %q", entries[0].Status) + if entries[0].Status != "ready" { + t.Fatalf("expected ready status, got %q", entries[0].Status) } } @@ -773,8 +1175,16 @@ func TestBuildPreparationGroupsExactMatchingUnit3DDescriptions(t *testing.T) { registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "AITHER", group: "unit3d", description: "same description"}, - stubPreparationDefinition{name: "HHD", group: "unit3d", description: "same description"}, + stubPreparationDefinition{ + name: "AITHER", + group: "unit3d", + description: "same description", + }, + stubPreparationDefinition{ + name: "HHD", + group: "unit3d", + description: "same description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -782,7 +1192,7 @@ func TestBuildPreparationGroupsExactMatchingUnit3DDescriptions(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{}, []string{"AITHER", "HHD"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{}), []string{"AITHER", "HHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -803,8 +1213,16 @@ func TestBuildPreparationSplitsSameGroupWhenDescriptionDiffers(t *testing.T) { registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "AITHER", group: "unit3d", description: "aither description"}, - stubPreparationDefinition{name: "HHD", group: "unit3d", description: "hhd description"}, + stubPreparationDefinition{ + name: "AITHER", + group: "unit3d", + description: "aither description", + }, + stubPreparationDefinition{ + name: "HHD", + group: "unit3d", + description: "hhd description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -812,7 +1230,7 @@ func TestBuildPreparationSplitsSameGroupWhenDescriptionDiffers(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{}, []string{"AITHER", "HHD"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{}), []string{"AITHER", "HHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -835,7 +1253,7 @@ func TestBuildPreparationSplitsSameGroupWhenDescriptionDiffers(t *testing.T) { t.Fatalf("expected stable HHD group key, got %q", preview.Descriptions[1].GroupKey) } - reversed, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{}, []string{"HHD", "AITHER"}) + reversed, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{}), []string{"HHD", "AITHER"}) if err != nil { t.Fatalf("unexpected reversed error: %v", err) } @@ -858,8 +1276,16 @@ func TestBuildPreparationGroupsSameFinalDescriptionWhenExtractedDescriptionDiffe registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "AITHER", group: "unit3d", description: "same final description"}, - stubPreparationDefinition{name: "BLU", group: "unit3d", description: "same final description"}, + stubPreparationDefinition{ + name: "AITHER", + group: "unit3d", + description: "same final description", + }, + stubPreparationDefinition{ + name: "BLU", + group: "unit3d", + description: "same final description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -875,7 +1301,7 @@ func TestBuildPreparationGroupsSameFinalDescriptionWhenExtractedDescriptionDiffe } svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: sourcePath}, []string{"AITHER", "BLU"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: sourcePath}), []string{"AITHER", "BLU"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -895,8 +1321,16 @@ func TestBuildPreparationGroupsSameDescriptionWhenImageHostFeedbackDiffers(t *te registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "AITHER", group: "shared", description: "same description"}, - stubPreparationDefinition{name: "PTP", group: "shared", description: "same description"}, + stubPreparationDefinition{ + name: "AITHER", + group: "shared", + description: "same description", + }, + stubPreparationDefinition{ + name: "PTP", + group: "shared", + description: "same description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -904,7 +1338,7 @@ func TestBuildPreparationGroupsSameDescriptionWhenImageHostFeedbackDiffers(t *te } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{}, []string{"AITHER", "PTP"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{}), []string{"AITHER", "PTP"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -924,8 +1358,16 @@ func TestBuildPreparationGroupsUnit3DWhenImageHostMessageOnlyDiffers(t *testing. registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "AITHER", group: "unit3d", description: "same description"}, - stubPreparationDefinition{name: "HHD", group: "unit3d", description: "same description"}, + stubPreparationDefinition{ + name: "AITHER", + group: "unit3d", + description: "same description", + }, + stubPreparationDefinition{ + name: "HHD", + group: "unit3d", + description: "same description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -943,7 +1385,7 @@ func TestBuildPreparationGroupsUnit3DWhenImageHostMessageOnlyDiffers(t *testing. repo := &stubRepo{} svc := NewServiceWithRegistry(cfg, nil, repo, registry) sourcePath := filepath.Join(t.TempDir(), "source.mkv") - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: sourcePath}, []string{"AITHER", "HHD"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: sourcePath}), []string{"AITHER", "HHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1047,7 +1489,7 @@ func TestBuildPreparationUnit3DGroupsOnConfiguredHostPreference(t *testing.T) { }, } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: sourcePath}, []string{"HHD", "LUME", "RAS"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: sourcePath}), []string{"HHD", "LUME", "RAS"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1075,10 +1517,18 @@ func TestPreparationDescriptionGroupMergesUnit3DHostVariantsWhenDescriptionMatch RawDescriptionHTML: "

raw

", Description: "same description", DescriptionHTML: "

same description

", - ImageHost: api.ImageHostFeedback{Status: "reused", SelectedHost: "pixhost", AllowedHosts: []string{"pixhost"}}, + ImageHost: api.ImageHostFeedback{ + Status: "reused", + SelectedHost: "pixhost", + AllowedHosts: []string{"pixhost"}, + }, } second := first - second.ImageHost = api.ImageHostFeedback{Status: "reused", SelectedHost: "imgbb", AllowedHosts: []string{"imgbb"}} + second.ImageHost = api.ImageHostFeedback{ + Status: "reused", + SelectedHost: "imgbb", + AllowedHosts: []string{"imgbb"}, + } second.RawDescriptionHTML = "
raw
" second.DescriptionHTML = "
same description
" @@ -1139,7 +1589,7 @@ func TestUploadAggregatesUploadedTorrentArtifacts(t *testing.T) { cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}} svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1169,8 +1619,16 @@ func TestUploadPreflightsMultipleConfiguredImageHostsOnce(t *testing.T) { repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, } images := &stubImageService{repo: repo} @@ -1185,7 +1643,7 @@ func TestUploadPreflightsMultipleConfiguredImageHostsOnce(t *testing.T) { } svc := NewServiceWithRegistryAndImages(cfg, nil, repo, registry, images) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}) if err != nil { t.Fatalf("unexpected upload error: %v", err) } @@ -1198,7 +1656,7 @@ func TestUploadPreflightsMultipleConfiguredImageHostsOnce(t *testing.T) { t.Fatalf("expected one upload per configured host, got %q", got) } - summary, err = svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}) + summary, err = svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}) if err != nil { t.Fatalf("unexpected second upload error: %v", err) } @@ -1222,18 +1680,26 @@ func TestUploadPreflightsUnrestrictedTrackerToFirstConfiguredImageHost(t *testin repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, } - images := &blockingImageService{} + images := &blockingImageService{repo: repo} cfg := config.Config{ ImageHosting: config.ImageHostingConfig{Host1: "imgbb", Host2: "pixhost"}, Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"RHD"}}, } svc := NewServiceWithRegistryAndImages(cfg, nil, repo, registry, images) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}) if err != nil { t.Fatalf("unexpected upload error: %v", err) } @@ -1254,12 +1720,20 @@ func TestUploadPreparesDistinctTrackerArtifactsBeforeConcurrentUploads(t *testin torrentPath := filepath.Join(tmp, "release.torrent") createServiceTestTorrent(t, filepath.Join(tmp, "source.bin"), torrentPath) - requests := make(chan UploadRequest, 2) + requests := make(chan PreparationInput, 2) release := make(chan struct{}) registry := NewRegistry() for _, definition := range []Definition{ - trackingUploadDefinition{name: "HDB", requests: requests, release: release}, - trackingUploadDefinition{name: "PTP", requests: requests, release: release}, + trackingUploadDefinition{ + name: "HDB", + requests: requests, + release: release, + }, + trackingUploadDefinition{ + name: "PTP", + requests: requests, + release: release, + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -1282,8 +1756,10 @@ func TestUploadPreparesDistinctTrackerArtifactsBeforeConcurrentUploads(t *testin summary api.UploadSummary err error }, 1) + progress := make(chan api.UploadProgressUpdate, 32) + uploadCtx := api.WithUploadProgressReporter(context.Background(), func(update api.UploadProgressUpdate) { progress <- update }) go func() { - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{ + summary, err := svc.Upload(uploadCtx, api.UploadSubject{ SourcePath: sourcePath, TorrentPath: torrentPath, }) @@ -1293,14 +1769,19 @@ func TestUploadPreparesDistinctTrackerArtifactsBeforeConcurrentUploads(t *testin }{summary: summary, err: err} }() - received := make(map[string]UploadRequest, 2) - timeout := time.After(2 * time.Second) + received := make(map[string]PreparationInput, 2) + updates := make([]api.UploadProgressUpdate, 0) + timeout := time.After(10 * time.Second) for len(received) < 2 { select { case req := <-requests: received[req.Tracker] = req + case result := <-done: + t.Fatalf("upload returned before both submissions: summary=%#v err=%v received=%v", result.summary, result.err, received) + case update := <-progress: + updates = append(updates, update) case <-timeout: - t.Fatalf("timed out waiting for upload requests, got %d", len(received)) + t.Fatalf("timed out waiting for upload requests, got %d updates=%#v", len(received), updates) } } @@ -1338,8 +1819,16 @@ func TestBuildPreparationPreflightsMultipleConfiguredImageHostsConcurrently(t *t repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, } started := make(chan string, 2) @@ -1359,7 +1848,7 @@ func TestBuildPreparationPreflightsMultipleConfiguredImageHostsConcurrently(t *t done := make(chan error, 1) go func() { - _, err := svc.BuildPreparation(ctx, api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"PTP", "STC"}) + _, err := svc.BuildPreparation(ctx, api.NewDescriptionSubject(api.UploadSubject{SourcePath: "/tmp/source"}), []string{"PTP", "STC"}) done <- err }() @@ -1390,31 +1879,6 @@ func TestBuildPreparationPreflightsMultipleConfiguredImageHostsConcurrently(t *t } } -func TestUploadIncludesRuleFailedTrackersWhenOverrideEnabled(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - if err := registry.Register(stubUploadArtifactDefinition{name: "AITHER"}); err != nil { - t.Fatalf("register stub: %v", err) - } - - cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}} - svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{ - SourcePath: "/tmp/file", - IgnoreTrackerRuleFailures: true, - TrackerRuleFailures: map[string][]api.RuleFailure{ - "AITHER": {{Rule: "require_movie_only", Reason: "movie only"}}, - }, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if summary.Uploaded != 1 { - t.Fatalf("expected 1 upload, got %d", summary.Uploaded) - } -} - func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { t.Parallel() @@ -1423,9 +1887,24 @@ func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { registry := NewRegistry() definitions := []Definition{ - blockingUploadDefinition{name: "BLU", started: started, release: release, uploaded: 1}, - blockingUploadDefinition{name: "BHD", started: started, release: release, uploaded: 1}, - blockingUploadDefinition{name: "AITHER", started: started, release: release, uploaded: 1}, + blockingUploadDefinition{ + name: "BLU", + started: started, + release: release, + uploaded: 1, + }, + blockingUploadDefinition{ + name: "BHD", + started: started, + release: release, + uploaded: 1, + }, + blockingUploadDefinition{ + name: "AITHER", + started: started, + release: release, + uploaded: 1, + }, } for _, definition := range definitions { if err := registry.Register(definition); err != nil { @@ -1445,7 +1924,7 @@ func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { } resultCh := make(chan uploadResult, 1) go func() { - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) resultCh <- uploadResult{summary: summary, err: err} }() @@ -1454,7 +1933,7 @@ func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { select { case tracker := <-started: seen[tracker] = struct{}{} - case <-time.After(500 * time.Millisecond): + case <-time.After(10 * time.Second): t.Fatalf("timed out waiting for initial tracker starts") } } @@ -1465,14 +1944,14 @@ func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { select { case tracker := <-started: t.Fatalf("expected third tracker to wait for worker slot, started early: %s", tracker) - case <-time.After(120 * time.Millisecond): + default: } close(release) select { case <-started: - case <-time.After(500 * time.Millisecond): + case <-time.After(10 * time.Second): t.Fatalf("timed out waiting for third tracker to start") } @@ -1484,7 +1963,7 @@ func TestUploadRunsTrackersConcurrentlyWithLimit(t *testing.T) { if result.summary.Uploaded != 3 { t.Fatalf("expected 3 uploads, got %d", result.summary.Uploaded) } - case <-time.After(2 * time.Second): + case <-time.After(10 * time.Second): t.Fatalf("timed out waiting for upload result") } } @@ -1494,14 +1973,18 @@ func TestUploadReportsCancellationAfterCompletedTrackerUpload(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) registry := NewRegistry() - if err := registry.Register(cancelAfterUploadDefinition{name: "AITHER", cancel: cancel, uploaded: 1}); err != nil { + if err := registry.Register(cancelAfterUploadDefinition{ + name: "AITHER", + cancel: cancel, + uploaded: 1, + }); err != nil { t.Fatalf("register AITHER: %v", err) } cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}} svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation after upload, got %v", err) } @@ -1524,7 +2007,7 @@ func TestUploadCanceledBeforeStartReturnsZeroUpload(t *testing.T) { cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}} svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation, got %v", err) } @@ -1538,7 +2021,11 @@ func TestUploadCancellationKeepsCompletedTrackerOnly(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) registry := NewRegistry() - if err := registry.Register(cancelAfterUploadDefinition{name: "AITHER", cancel: cancel, uploaded: 1}); err != nil { + if err := registry.Register(cancelAfterUploadDefinition{ + name: "AITHER", + cancel: cancel, + uploaded: 1, + }); err != nil { t.Fatalf("register AITHER: %v", err) } if err := registry.Register(blockingUploadDefinition{name: "BLU", uploaded: 1}); err != nil { @@ -1552,7 +2039,7 @@ func TestUploadCancellationKeepsCompletedTrackerOnly(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation, got %v", err) } @@ -1582,7 +2069,11 @@ func TestUploadStatusFailureDoesNotCancelCompletedTracker(t *testing.T) { cancel: cancel, uploaded: 1, uploadedTorrents: []api.UploadedTorrent{ - {Tracker: "AITHER", TorrentID: "1", DownloadURL: "https://aither.cc/torrent/download/1"}, + { + Tracker: "AITHER", + TorrentID: "1", + DownloadURL: "https://aither.cc/torrent/download/1", + }, }, }); err != nil { t.Fatalf("register AITHER: %v", err) @@ -1598,7 +2089,7 @@ func TestUploadStatusFailureDoesNotCancelCompletedTracker(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation, got %v", err) } @@ -1641,7 +2132,11 @@ func TestUploadCancellationFinalizesPendingWithCleanupContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) registry := NewRegistry() - if err := registry.Register(cancelAfterUploadDefinition{name: "AITHER", cancel: cancel, uploaded: 1}); err != nil { + if err := registry.Register(cancelAfterUploadDefinition{ + name: "AITHER", + cancel: cancel, + uploaded: 1, + }); err != nil { t.Fatalf("register AITHER: %v", err) } if err := registry.Register(blockingUploadDefinition{name: "BLU", uploaded: 1}); err != nil { @@ -1655,7 +2150,7 @@ func TestUploadCancellationFinalizesPendingWithCleanupContext(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context cancellation, got %v", err) } @@ -1700,7 +2195,7 @@ func TestUploadLateCancellationUsesCleanupContextForStatusWrites(t *testing.T) { cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"AITHER"}}} svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1746,7 +2241,7 @@ func TestUploadBestEffortWithFailures(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, nil, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err == nil { t.Fatal("expected failed tracker error for best-effort upload") } @@ -1779,7 +2274,7 @@ func TestUploadBestEffortWithFailuresAndRepoReturnsError(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err == nil { t.Fatal("expected failed tracker error for best-effort upload") } @@ -1794,7 +2289,11 @@ func TestUploadBestEffortWithFailuresAndRepoReturnsError(t *testing.T) { for _, update := range repo.statusUpdates { finalStatus[update.tracker] = update.status } - for tracker, wantStatus := range map[string]string{"BLU": "uploaded", "BHD": "failed", "AITHER": "uploaded"} { + for tracker, wantStatus := range map[string]string{ + "BLU": "uploaded", + "BHD": "failed", + "AITHER": "uploaded", + } { if finalStatus[tracker] != wantStatus { t.Fatalf("expected %s status %q, got %q", tracker, wantStatus, finalStatus[tracker]) } @@ -1829,7 +2328,7 @@ func TestUploadRetriesTransientUploadedStatusFailure(t *testing.T) { svc := NewServiceWithRegistry(cfg, nil, repo, registry) ctx := context.Background() - summary, err := svc.Upload(ctx, api.PreparedMetadata{SourcePath: "/tmp/file"}) + summary, err := svc.Upload(ctx, api.UploadSubject{SourcePath: "/tmp/file"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1870,7 +2369,7 @@ func TestUploadAllFailuresReturnsError(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, nil, registry) - _, err := svc.Upload(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/file"}) + _, err := svc.Upload(context.Background(), api.UploadSubject{SourcePath: "/tmp/file"}) if err == nil { t.Fatalf("expected error when all trackers fail") } @@ -1881,8 +2380,16 @@ func TestBuildPreparationSeparatesScopedImageHostGroups(t *testing.T) { registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "HDB", group: "shared", description: "same description"}, - stubPreparationDefinition{name: "AITHER", group: "shared", description: "same description"}, + stubPreparationDefinition{ + name: "HDB", + group: "shared", + description: "same description", + }, + stubPreparationDefinition{ + name: "AITHER", + group: "shared", + description: "same description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -1891,14 +2398,54 @@ func TestBuildPreparationSeparatesScopedImageHostGroups(t *testing.T) { repo := &stubRepo{ selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, uploads: []api.UploadedImageLink{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Host: "hdb", UsageScope: "tracker:HDB", ImgURL: "https://hdb/a.png", RawURL: "https://hdb/a.png", WebURL: "https://hdb/a"}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Host: "hdb", UsageScope: "tracker:HDB", ImgURL: "https://hdb/b.png", RawURL: "https://hdb/b.png", WebURL: "https://hdb/b"}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/a.png", RawURL: "https://imgbb/a.png", WebURL: "https://imgbb/a"}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/b.png", RawURL: "https://imgbb/b.png", WebURL: "https://imgbb/b"}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Host: "hdb", + UsageScope: "tracker:HDB", + ImgURL: "https://hdb/a.png", + RawURL: "https://hdb/a.png", + WebURL: "https://hdb/a", + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Host: "hdb", + UsageScope: "tracker:HDB", + ImgURL: "https://hdb/b.png", + RawURL: "https://hdb/b.png", + WebURL: "https://hdb/b", + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/a.png", + RawURL: "https://imgbb/a.png", + WebURL: "https://imgbb/a", + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/b.png", + RawURL: "https://imgbb/b.png", + WebURL: "https://imgbb/b", + }, }, } cfg := config.Config{ @@ -1910,7 +2457,7 @@ func TestBuildPreparationSeparatesScopedImageHostGroups(t *testing.T) { } svc := NewServiceWithRegistry(cfg, nil, repo, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"HDB", "AITHER"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: "/tmp/source"}), []string{"HDB", "AITHER"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1936,7 +2483,7 @@ func TestBuildPreparationRehostsHDBScreenshotsForURLOnlySlots(t *testing.T) { }, }, } - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ SourcePath: `D:\TV\The.Pitt.S02E15.1080p.WEB-DL.mkv`, Options: api.UploadOptions{KeepImages: true}, TrackerData: []api.TrackerMetadata{{ @@ -1948,7 +2495,7 @@ func TestBuildPreparationRehostsHDBScreenshotsForURLOnlySlots(t *testing.T) { if err != nil { t.Fatalf("tmp root: %v", err) } - tmpDir, _, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, _, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { t.Fatalf("release temp dir: %v", err) } @@ -1979,14 +2526,30 @@ func TestBuildPreparationRehostsHDBScreenshotsForURLOnlySlots(t *testing.T) { images := &stubImageService{ uploads: map[string][]api.UploadedImageLink{ "hdb": { - {SourcePath: meta.SourcePath, ImagePath: firstPath, Host: "hdb", UsageScope: "tracker:HDB", ImgURL: "https://t.hdbits.org/51q8jo2.jpg", RawURL: "https://img.hdbits.org/51q8jo2.jpg", WebURL: "https://img.hdbits.org/51q8jo2"}, - {SourcePath: meta.SourcePath, ImagePath: secondPath, Host: "hdb", UsageScope: "tracker:HDB", ImgURL: "https://t.hdbits.org/w0S7ltI.jpg", RawURL: "https://img.hdbits.org/w0S7ltI.jpg", WebURL: "https://img.hdbits.org/w0S7ltI"}, + { + SourcePath: meta.SourcePath, + ImagePath: firstPath, + Host: "hdb", + UsageScope: "tracker:HDB", + ImgURL: "https://t.hdbits.org/51q8jo2.jpg", + RawURL: "https://img.hdbits.org/51q8jo2.jpg", + WebURL: "https://img.hdbits.org/51q8jo2", + }, + { + SourcePath: meta.SourcePath, + ImagePath: secondPath, + Host: "hdb", + UsageScope: "tracker:HDB", + ImgURL: "https://t.hdbits.org/w0S7ltI.jpg", + RawURL: "https://img.hdbits.org/w0S7ltI.jpg", + WebURL: "https://img.hdbits.org/w0S7ltI", + }, }, }, } svc := NewServiceWithRegistryAndImages(cfg, api.NopLogger{}, repo, registry, images) - preview, err := svc.BuildPreparation(context.Background(), meta, []string{"HDB"}) + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(meta), []string{"HDB"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2010,9 +2573,21 @@ func TestBuildPreparationPreloadsDescriptionAssetQueriesOnce(t *testing.T) { registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "HDB", group: "hdb", description: "same description"}, - stubPreparationDefinition{name: "AITHER", group: "aither", description: "same description"}, - stubPreparationDefinition{name: "BHD", group: "bhd", description: "same description"}, + stubPreparationDefinition{ + name: "HDB", + group: "hdb", + description: "same description", + }, + stubPreparationDefinition{ + name: "AITHER", + group: "aither", + description: "same description", + }, + stubPreparationDefinition{ + name: "BHD", + group: "bhd", + description: "same description", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -2025,17 +2600,41 @@ func TestBuildPreparationPreloadsDescriptionAssetQueriesOnce(t *testing.T) { ImageURLs: []string{"https://imgbb.com/a.png", "https://imgbb.com/b.png"}, }}, selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, uploads: []api.UploadedImageLink{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/a.png", RawURL: "https://imgbb/a.png", WebURL: "https://imgbb/a"}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/b.png", RawURL: "https://imgbb/b.png", WebURL: "https://imgbb/b"}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/a.png", + RawURL: "https://imgbb/a.png", + WebURL: "https://imgbb/a", + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/b.png", + RawURL: "https://imgbb/b.png", + WebURL: "https://imgbb/b", + }, }, } svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) - _, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"HDB", "AITHER", "BHD"}) + _, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{SourcePath: "/tmp/source"}), []string{"HDB", "AITHER", "BHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2053,13 +2652,21 @@ func TestBuildPreparationPreloadsDescriptionAssetQueriesOnce(t *testing.T) { } } -func TestBuildPreparationSkipsBlockedTrackers(t *testing.T) { +func TestBuildPreparationDoesNotImportUploadOutcomeBlocks(t *testing.T) { t.Parallel() registry := NewRegistry() for _, definition := range []Definition{ - stubPreparationDefinition{name: "HDB", group: "hdb", description: "hdb"}, - stubPreparationDefinition{name: "BHD", group: "bhd", description: "bhd"}, + stubPreparationDefinition{ + name: "HDB", + group: "hdb", + description: "hdb", + }, + stubPreparationDefinition{ + name: "BHD", + group: "bhd", + description: "bhd", + }, } { if err := registry.Register(definition); err != nil { t.Fatalf("register stub: %v", err) @@ -2067,20 +2674,20 @@ func TestBuildPreparationSkipsBlockedTrackers(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - preview, err := svc.BuildPreparation(context.Background(), api.PreparedMetadata{ + preview, err := svc.BuildPreparation(context.Background(), api.NewDescriptionSubject(api.UploadSubject{ SourcePath: "/tmp/source", BlockedTrackers: map[string][]api.TrackerBlockReason{ "HDB": {api.TrackerBlockReasonDupe}, }, - }, []string{"HDB", "BHD"}) + }), + + []string{"HDB", "BHD"}) + if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(preview.Descriptions) != 1 { - t.Fatalf("expected 1 description, got %d", len(preview.Descriptions)) - } - if len(preview.Descriptions[0].Trackers) != 1 || preview.Descriptions[0].Trackers[0] != "BHD" { - t.Fatalf("expected only BHD in preparation preview, got %#v", preview.Descriptions[0].Trackers) + if len(preview.Descriptions) != 2 { + t.Fatalf("expected both description-owned tracker groups, got %d", len(preview.Descriptions)) } } @@ -2098,7 +2705,7 @@ func TestBuildUploadDryRunIncludesBlockedTrackers(t *testing.T) { } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{ + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{ SourcePath: "/tmp/source", BlockedTrackers: map[string][]api.TrackerBlockReason{ "HDB": {api.TrackerBlockReasonDupe}, @@ -2119,12 +2726,12 @@ func TestBuildUploadDryRunAnnotatesBannedGroupWithoutBlockingPayload(t *testing. t.Parallel() registry := NewRegistry() - if err := registry.Register(stubDryRunDefinition{name: "ANT"}); err != nil { + if err := registry.Register(stubDryRunDefinition{name: "ANT", bannedGroups: []string{"AFG"}}); err != nil { t.Fatalf("register stub: %v", err) } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{ + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{ SourcePath: "/tmp/source", Tag: "-AFG", }, []string{"ANT"}) @@ -2153,12 +2760,16 @@ func TestBuildUploadDryRunAnnotatesBannedGroupOnBuilderError(t *testing.T) { t.Parallel() registry := NewRegistry() - if err := registry.Register(stubDryRunDefinition{name: "ANT", dryRunErr: errors.New("build failed")}); err != nil { + if err := registry.Register(stubDryRunDefinition{ + name: "ANT", + dryRunErr: errors.New("build failed"), + bannedGroups: []string{"AFG"}, + }); err != nil { t.Fatalf("register stub: %v", err) } svc := NewServiceWithRegistry(config.Config{}, nil, nil, registry) - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{ + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{ SourcePath: "/tmp/source", Tag: "-AFG", }, []string{"ANT"}) @@ -2208,7 +2819,7 @@ func TestBuildUploadDryRunReportsBannedCheckErrorOnBuilderError(t *testing.T) { t.Fatalf("write invalid banned cache: %v", err) } - entries, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{ + entries, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{ SourcePath: "/tmp/source", Tag: "-ExampleGRP", }, []string{"BLU"}) @@ -2248,7 +2859,7 @@ func TestUploadSkipsBlockedTrackersBeforePendingRecords(t *testing.T) { cfg := config.Config{Trackers: config.TrackersConfig{DefaultTrackers: config.CSVList{"HDB", "AITHER"}}} svc := NewServiceWithRegistry(cfg, nil, repo, registry) - summary, err := svc.Upload(context.Background(), api.PreparedMetadata{ + summary, err := svc.Upload(context.Background(), api.UploadSubject{ SourcePath: "/tmp/source", BlockedTrackers: map[string][]api.TrackerBlockReason{ "HDB": {api.TrackerBlockReasonDupe}, @@ -2294,17 +2905,41 @@ func TestBuildUploadDryRunPreloadsDescriptionAssetQueriesOnce(t *testing.T) { ImageURLs: []string{"https://imgbb.com/a.png", "https://imgbb.com/b.png"}, }}, selections: []api.ScreenshotFinalSelection{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Order: 0}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Order: 1}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Order: 0, + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Order: 1, + }, }, uploads: []api.UploadedImageLink{ - {SourcePath: "/tmp/source", ImagePath: "/tmp/a.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/a.png", RawURL: "https://imgbb/a.png", WebURL: "https://imgbb/a"}, - {SourcePath: "/tmp/source", ImagePath: "/tmp/b.png", Host: "imgbb", UsageScope: "global", ImgURL: "https://imgbb/b.png", RawURL: "https://imgbb/b.png", WebURL: "https://imgbb/b"}, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/a.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/a.png", + RawURL: "https://imgbb/a.png", + WebURL: "https://imgbb/a", + }, + { + SourcePath: "/tmp/source", + ImagePath: "/tmp/b.png", + Host: "imgbb", + UsageScope: "global", + ImgURL: "https://imgbb/b.png", + RawURL: "https://imgbb/b.png", + WebURL: "https://imgbb/b", + }, }, } svc := NewServiceWithRegistry(config.Config{}, nil, repo, registry) - _, err := svc.BuildUploadDryRun(context.Background(), api.PreparedMetadata{SourcePath: "/tmp/source"}, []string{"HDB", "AITHER", "BHD"}) + _, err := svc.BuildUploadDryRun(context.Background(), api.UploadSubject{SourcePath: "/tmp/source"}, []string{"HDB", "AITHER", "BHD"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2356,27 +2991,3 @@ func assertTrackerArtifact(t *testing.T, torrentPath string, wantAnnounce string t.Fatalf("expected source %q, got %q", wantSource, info.Source) } } - -func TestFilterTrackersByRuleFailuresExcludesModifiedReleaseAcrossFamilies(t *testing.T) { - t.Parallel() - - failures := map[string][]api.RuleFailure{ - "PTP": {{Rule: "modified_release", Reason: "source renamed from original release name"}}, - "LST": {{Rule: "modified_release", Reason: "source renamed from original release name"}}, - "HDB": {{Rule: "recommended_id", Reason: "recommended ID missing", Severity: api.RuleFailureSeverityWarning}}, - } - trackers := []string{"PTP", "LST", "HDB"} - - got := filterTrackersByRuleFailures(trackers, failures, false, nil) - if slices.Contains(got, "PTP") || slices.Contains(got, "LST") { - t.Fatalf("expected PTP and LST skipped for modified_release, got %v", got) - } - if !slices.Contains(got, "HDB") { - t.Fatalf("expected warning-only HDB retained, got %v", got) - } - - // The override flag must let the user force-upload past the failure. - if got := filterTrackersByRuleFailures(trackers, failures, true, nil); !slices.Contains(got, "PTP") || !slices.Contains(got, "LST") { - t.Fatalf("expected ignore=true to retain all trackers, got %v", got) - } -} diff --git a/internal/trackers/service_upload_plan.go b/internal/trackers/service_upload_plan.go new file mode 100644 index 00000000..56a31ddb --- /dev/null +++ b/internal/trackers/service_upload_plan.go @@ -0,0 +1,501 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/autobrr/upbrr/internal/config" + internalerrors "github.com/autobrr/upbrr/internal/errors" + "github.com/autobrr/upbrr/internal/logging" + "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/pkg/api" +) + +const defaultMaxConcurrentTrackerPreparations = 4 + +// TrackerFailure is one normalized tracker-local preparation, record, or submission failure. +type TrackerFailure struct { + // Tracker is the normalized tracker attributed to the failure. + Tracker string + // Code is the stable preparation, record, or submission failure class. + Code string + // Message contains sanitized operator-facing detail. + Message string + cause error +} + +// PartialUploadError reports tracker-local failures without discarding successful outcomes. +type PartialUploadError struct{ failures []TrackerFailure } + +// Error returns a deterministic sanitized summary of tracker-local failures. +func (e *PartialUploadError) Error() string { + if e == nil || len(e.failures) == 0 { + return "trackers: partial upload failure" + } + parts := make([]string, 0, len(e.failures)) + for _, failure := range e.failures { + parts = append(parts, failure.Tracker+": "+failure.Message) + } + return "trackers: " + strings.Join(parts, "; ") +} + +// Failures returns a defensive copy in resolved tracker order. +func (e *PartialUploadError) Failures() []TrackerFailure { + if e == nil { + return nil + } + return append([]TrackerFailure(nil), e.failures...) +} + +// TrackerLocalUploadFailures implements api.TrackerLocalUploadError without exposing private plan state. +func (e *PartialUploadError) TrackerLocalUploadFailures() []string { + if e == nil { + return nil + } + trackers := make([]string, 0, len(e.failures)) + for _, failure := range e.failures { + trackers = append(trackers, failure.Tracker) + } + return trackers +} + +// Unwrap returns diagnostic causes for tracker failures that retained one. +func (e *PartialUploadError) Unwrap() []error { + if e == nil { + return nil + } + causes := make([]error, 0, len(e.failures)) + for _, failure := range e.failures { + if failure.cause != nil { + causes = append(causes, failure.cause) + } + } + return causes +} + +// IsPartialUploadError reports whether err represents only tracker-local failures. +func IsPartialUploadError(err error) bool { + var partial *PartialUploadError + return errors.As(err, &partial) +} + +type trackerPlanSlot struct { + tracker string + plan TrackerPlan + failure *TrackerFailure + summary api.UploadSummary + canceledDuringSubmission bool +} + +// Upload prepares every selected tracker, reaches a full barrier, then submits +// ready plans concurrently. Tracker-local failures do not stop unrelated work +// and return a partial summary with [PartialUploadError]; cancellation returns +// completed uploads with the context error. Pending record finalization uses a +// bounded context detached from caller cancellation. +func (s *Service) Upload(ctx context.Context, meta api.UploadSubject) (api.UploadSummary, error) { + if err := ctx.Err(); err != nil { + return api.UploadSummary{}, fmt.Errorf("context canceled: %w", err) + } + if strings.TrimSpace(meta.SourcePath) == "" { + return api.UploadSummary{}, errors.New("trackers: prepared release source is missing") + } + resolved := resolveTrackers(s.cfg, meta.Trackers, meta.TrackersRemove) + if len(resolved) > 0 && s.registry == nil { + return api.UploadSummary{}, errors.New("trackers: registry not configured") + } + resolved = filterKnownTrackersWithRegistry(resolved, s.logger, s.registry) + resolved = filterTrackersByBlocks(resolved, meta.BlockedTrackers, s.logger) + if len(resolved) == 0 { + s.logger.Infof("trackers: no trackers configured, skipping upload") + return api.UploadSummary{}, nil + } + if baseTorrent, err := ResolveUploadTorrentPath(meta, s.cfg.MainSettings.DBPath); err == nil { + meta.TorrentPath = baseTorrent + } else if !isUploadTorrentNotFound(err) { + return api.UploadSummary{}, fmt.Errorf("trackers: shared upload torrent: %w", err) + } + + preflight := s.preflightDescriptionImageHosts(ctx, meta, resolved) + banned := s.prepareBannedState(ctx, meta, resolved) + slots := s.prepareUploadPlans(ctx, meta, resolved, preflight, banned) + if err := ctx.Err(); err != nil { + s.releaseTrackerPlans(slots) + for idx := range slots { + emitTrackerPlanProgress(ctx, meta.SourcePath, slots[idx].tracker, "tracker_upload", "canceled", "Upload canceled before submission") + } + return summarizeTrackerPlanSlots(slots), fmt.Errorf("context canceled: %w", err) + } + + s.createPendingRecords(ctx, meta, slots) + s.submitTrackerPlans(ctx, meta, slots) + s.releaseTrackerPlans(slots) + + summary := summarizeTrackerPlanSlots(slots) + if err := ctx.Err(); err != nil && trackerPlansCanceled(slots) { + return summary, fmt.Errorf("context canceled: %w", err) + } + failures := trackerPlanFailures(slots) + if len(failures) > 0 { + return summary, &PartialUploadError{failures: failures} + } + return summary, nil +} + +func (s *Service) prepareBannedState(ctx context.Context, meta api.UploadSubject, resolved []string) map[string]*TrackerFailure { + group := NormalizeBannedReleaseGroup(meta.Tag) + if group == "" || s.banned == nil { + return nil + } + failures := make(map[string]*TrackerFailure) + for _, tracker := range resolved { + if err := s.banned.RefreshDynamic(ctx, s.cfg, []string{tracker}, s.logger); err != nil { + failures[normalizeTrackerName(tracker)] = &TrackerFailure{ + Tracker: tracker, + Code: "banned_check", + Message: safeTrackerMessage(err), + cause: err, + } + continue + } + isBanned, err := s.banned.IsBanned(tracker, group) + switch { + case err != nil: + failures[normalizeTrackerName(tracker)] = &TrackerFailure{ + Tracker: tracker, + Code: "banned_check", + Message: safeTrackerMessage(err), + cause: err, + } + case isBanned: + failures[normalizeTrackerName(tracker)] = &TrackerFailure{ + Tracker: tracker, + Code: "banned_group", + Message: fmt.Sprintf("release group %s is banned", group), + cause: internalerrors.ErrBannedGroup, + } + } + } + return failures +} + +func (s *Service) prepareUploadPlans( + ctx context.Context, + meta api.UploadSubject, + resolved []string, + preflight imageHostPreflight, + banned map[string]*TrackerFailure, +) []trackerPlanSlot { + slots := make([]trackerPlanSlot, len(resolved)) + workerCount := min(defaultMaxConcurrentTrackerPreparations, len(resolved)) + jobs := make(chan int) + var wg sync.WaitGroup + worker := func() { + defer wg.Done() + for idx := range jobs { + tracker := resolved[idx] + slot := trackerPlanSlot{tracker: tracker} + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "running", "Preparing tracker plan") + if failure := banned[normalizeTrackerName(tracker)]; failure != nil { + slot.failure = failure + slots[idx] = slot + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "failed", failure.Message) + continue + } + if err := ctx.Err(); err != nil { + slot.failure = trackerFailure(tracker, "canceled", err) + slots[idx] = slot + continue + } + definition, ok := s.registry.Lookup(tracker) + if !ok { + slot.failure = trackerFailure(tracker, "not_implemented", internalerrors.ErrNotImplemented) + slots[idx] = slot + continue + } + trackerCfg := applyTrackerConfigOverrides(trackerConfigFor(s.cfg, tracker), meta.TrackerConfigOverrides) + trackerMeta, err := PrepareTrackerUploadTorrentWithRegistry(meta, s.cfg.MainSettings.DBPath, tracker, trackerCfg, s.registry) + if err != nil { + slot.failure = trackerFailure(tracker, "artifact", err) + slots[idx] = slot + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "failed", slot.failure.Message) + continue + } + content := s.prepareUploadContent(ctx, tracker, trackerMeta, trackerCfg, nil, preflight) + if content.State == preparedUploadContentFailed { + slot.failure = &TrackerFailure{ + Tracker: tracker, + Code: string(content.Failure.Code), + Message: content.Failure.Message, + } + slots[idx] = slot + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "failed", slot.failure.Message) + continue + } + plan, failure := definition.Prepare(ctx, s.preparationInput(ctx, PreparationIntentUpload, tracker, trackerMeta, trackerCfg, content.Assets)) + if failure != nil { + slot.failure = &TrackerFailure{ + Tracker: tracker, + Code: failure.Code(), + Message: safeTrackerMessage(failure), + cause: failure, + } + slots[idx] = slot + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "failed", slot.failure.Message) + continue + } + slot.plan = plan + slots[idx] = slot + emitTrackerPlanProgress(ctx, meta.SourcePath, tracker, "tracker_preparation", "completed", "Tracker plan ready") + } + } + for range workerCount { + wg.Add(1) + go worker() + } + next := 0 +enqueue: + for ; next < len(resolved); next++ { + select { + case jobs <- next: + case <-ctx.Done(): + break enqueue + } + } + close(jobs) + wg.Wait() + for ; next < len(resolved); next++ { + slots[next] = trackerPlanSlot{ + tracker: resolved[next], + failure: trackerFailure(resolved[next], "canceled", ctx.Err()), + } + } + return slots +} + +func (s *Service) preparationInput( + ctx context.Context, + intent PreparationIntent, + tracker string, + meta api.UploadSubject, + trackerCfg config.TrackerConfig, + assets *DescriptionAssets, +) PreparationInput { + logger := logging.FromContext(ctx, s.logger) + input := PreparationInput{ + Intent: intent, + Tracker: tracker, + Meta: meta, + TrackerConfig: trackerCfg, + Runtime: PreparationRuntime{ + DBPath: s.cfg.MainSettings.DBPath, + Description: s.cfg.Description, + Internal: IsInternalGroup(s.cfg, tracker, meta), + BTNAPIToken: config.ResolveBTNAPIToken(s.cfg), + }, + Logger: logger, + Assets: assets, + } + selectedHost, err := PreferredImageUploadHostWithRegistry(s.registry, tracker, trackerCfg, meta.ImageHostOverrides) + if err != nil { + logger.Warnf("trackers: image upload target failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) + return input + } + input.SelectedImageHost = strings.ToLower(strings.TrimSpace(selectedHost)) + if s.images != nil && input.SelectedImageHost != "" { + host := input.SelectedImageHost + input.UploadImages = func(ctx context.Context, images []api.ScreenshotImage) ([]api.UploadedImageLink, error) { + return s.images.Upload(ctx, imageHostingSubject(meta), host, usageScopeForHost(s.registry, host), append([]api.ScreenshotImage(nil), images...)) + } + } + return input +} + +func (s *Service) createPendingRecords(ctx context.Context, meta api.UploadSubject, slots []trackerPlanSlot) { + if s.repo == nil { + return + } + for idx := range slots { + slot := &slots[idx] + if slot.failure != nil || slot.plan.Intent() != PreparationIntentUpload { + continue + } + status := "pending" + if IsInternalGroup(s.cfg, slot.tracker, meta) { + status = "pending-internal" + } + if err := s.repo.CreateUploadRecord(ctx, api.UploadRecord{ + Tracker: slot.tracker, + Status: status, + CreatedAt: time.Now().UTC(), + SourcePath: meta.SourcePath, + }); err != nil { + slot.failure = trackerFailure(slot.tracker, "record", err) + if releaseErr := slot.plan.Release(); releaseErr != nil { + s.warnPlanRelease(slot.tracker, releaseErr) + } + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "failed", slot.failure.Message) + continue + } + } +} + +func (s *Service) submitTrackerPlans(ctx context.Context, meta api.UploadSubject, slots []trackerPlanSlot) { + ready := make([]int, 0, len(slots)) + for idx := range slots { + if slots[idx].failure == nil && slots[idx].plan.Intent() == PreparationIntentUpload { + ready = append(ready, idx) + } + } + workerCount := s.maxConcurrentTrackerUploads(len(ready)) + if workerCount == 0 { + return + } + jobs := make(chan int) + var wg sync.WaitGroup + worker := func() { + defer wg.Done() + for idx := range jobs { + slot := &slots[idx] + if err := ctx.Err(); err != nil { + slot.failure = trackerFailure(slot.tracker, "canceled", err) + s.updateUploadRecord(ctx, meta.SourcePath, slot.tracker, "canceled") + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "canceled", "Upload canceled") + if releaseErr := slot.plan.Release(); releaseErr != nil { + s.warnPlanRelease(slot.tracker, releaseErr) + } + continue + } + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "running", "Uploading to tracker") + summary, err := slot.plan.Submit(ctx) + slot.canceledDuringSubmission = ctx.Err() != nil + if err != nil { + slot.failure = trackerFailure(slot.tracker, "submit", err) + s.updateUploadRecord(ctx, meta.SourcePath, slot.tracker, "failed") + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "failed", slot.failure.Message) + } else { + slot.summary = summary + s.updateUploadRecord(ctx, meta.SourcePath, slot.tracker, "uploaded") + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "completed", "Tracker upload complete") + } + if releaseErr := slot.plan.Release(); releaseErr != nil { + s.warnPlanRelease(slot.tracker, releaseErr) + } + } + } + for range workerCount { + wg.Add(1) + go worker() + } + next := 0 +enqueue: + for ; next < len(ready); next++ { + select { + case jobs <- ready[next]: + case <-ctx.Done(): + break enqueue + } + } + close(jobs) + wg.Wait() + for ; next < len(ready); next++ { + slot := &slots[ready[next]] + slot.failure = trackerFailure(slot.tracker, "canceled", ctx.Err()) + s.updateUploadRecord(ctx, meta.SourcePath, slot.tracker, "canceled") + emitTrackerPlanProgress(ctx, meta.SourcePath, slot.tracker, "tracker_upload", "canceled", "Upload canceled") + } +} + +func (s *Service) releaseTrackerPlans(slots []trackerPlanSlot) { + for idx := range slots { + if err := slots[idx].plan.Release(); err != nil { + s.warnPlanRelease(slots[idx].tracker, err) + } + } +} + +func (s *Service) updateUploadRecord(parent context.Context, sourcePath string, tracker string, status string) { + if s.repo == nil { + return + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), uploadRecordFinalizationTimeout) + defer cancel() + err := s.repo.UpdateLatestUploadRecordStatus(ctx, sourcePath, tracker, status) + if err != nil && ctx.Err() == nil { + err = s.repo.UpdateLatestUploadRecordStatus(ctx, sourcePath, tracker, status) + } + if err != nil { + s.logger.Warnf("trackers: status update failed tracker=%s status=%s err=%s", tracker, status, redaction.RedactValue(err.Error(), nil)) + } +} + +func (s *Service) warnPlanRelease(tracker string, err error) { + s.logger.Warnf("trackers: plan release failed tracker=%s err=%s", tracker, redaction.RedactValue(err.Error(), nil)) +} + +func summarizeTrackerPlanSlots(slots []trackerPlanSlot) api.UploadSummary { + summary := api.UploadSummary{} + for _, slot := range slots { + summary.Uploaded += slot.summary.Uploaded + summary.UploadedTorrents = append(summary.UploadedTorrents, slot.summary.UploadedTorrents...) + } + return summary +} + +func trackerPlanFailures(slots []trackerPlanSlot) []TrackerFailure { + failures := make([]TrackerFailure, 0) + for _, slot := range slots { + if slot.failure != nil && slot.failure.Code != "canceled" { + failures = append(failures, *slot.failure) + } + } + return failures +} + +func trackerFailure(tracker string, code string, err error) *TrackerFailure { + return &TrackerFailure{ + Tracker: tracker, + Code: code, + Message: safeTrackerMessage(err), + cause: err, + } +} + +func trackerPlansCanceled(slots []trackerPlanSlot) bool { + for _, slot := range slots { + if slot.canceledDuringSubmission || slot.failure != nil && slot.failure.Code == "canceled" { + return true + } + } + return false +} + +func safeTrackerMessage(err error) string { + if err == nil { + return "tracker operation failed" + } + message := strings.TrimSpace(redaction.RedactValue(err.Error(), nil)) + if message == "" { + return "tracker operation failed" + } + return message +} + +func normalizeTrackerName(tracker string) string { return strings.ToUpper(strings.TrimSpace(tracker)) } + +func emitTrackerPlanProgress(ctx context.Context, sourcePath string, tracker string, task string, status string, message string) { + api.EmitUploadProgress(ctx, api.UploadProgressUpdate{ + SourcePath: sourcePath, + Tracker: tracker, + Task: task, + Status: status, + Message: message, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + }) +} diff --git a/internal/trackers/unit3dmeta/metadata.go b/internal/trackers/unit3dmeta/metadata.go deleted file mode 100644 index 69eaa8d2..00000000 --- a/internal/trackers/unit3dmeta/metadata.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package unit3dmeta - -import ( - "fmt" - "os" - "sort" - "strings" - "sync" - - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/logging" -) - -var ( - trackerBaseURLs = map[string]string{} - initOnce sync.Once -) - -func initTrackers() { - initOnce.Do(func() { - cfg, err := config.LoadEmbeddedDefaultConfig() - if err != nil || cfg == nil || len(cfg.Trackers.Trackers) == 0 { - message := "no trackers configured" - if err != nil { - message = err.Error() - } - _, _ = fmt.Fprintf(os.Stderr, "unit3dmeta: error loading embedded default config: %s\n", logging.SanitizeMessage(message)) - return - } - - unit3DTrackers := []string{ - "A4K", "ACM", "AITHER", "BLU", "CBR", "DP", "EMUW", - "FRIKI", "HHD", "IHD", "ITT", "LCD", "LDU", "LST", "LT", - "LUME", "MNS", "OE", "OTW", "PT", "PTT", "R4E", "RAS", "RF", "RHD", - "SAM", "SHRI", "SP", "STC", "TIK", "TLZ", "TOS", "TTR", - "ULCX", "UTP", "YUS", "ZNTH", - } - - for _, name := range unit3DTrackers { - if trackerCfg, ok := cfg.Trackers.Trackers[name]; ok { - if strings.TrimSpace(trackerCfg.URL) != "" { - trackerBaseURLs[name] = trackerCfg.URL - } - } - } - }) -} - -func DefaultTracker() string { - return "AITHER" -} - -func Trackers() []string { - initTrackers() - trackers := make([]string, 0, len(trackerBaseURLs)) - for tracker := range trackerBaseURLs { - trackers = append(trackers, tracker) - } - sort.Strings(trackers) - return trackers -} - -func BaseURL(tracker string) (string, bool) { - initTrackers() - key := strings.ToUpper(strings.TrimSpace(tracker)) - if key == "" { - return "", false - } - baseURL, ok := trackerBaseURLs[key] - return baseURL, ok -} - -func IsKnown(tracker string) bool { - _, ok := BaseURL(tracker) - return ok -} diff --git a/internal/trackers/upload_artifact.go b/internal/trackers/upload_artifact.go index 31a81bc7..0680a6fa 100644 --- a/internal/trackers/upload_artifact.go +++ b/internal/trackers/upload_artifact.go @@ -14,57 +14,29 @@ import ( "github.com/anacrolix/torrent/metainfo" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/paths" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/torrentmeta" + torrentmeta "github.com/autobrr/upbrr/internal/torrent/metainfo" "github.com/autobrr/upbrr/pkg/api" ) -// trackerUploadTorrentSpec describes how tracker upload torrents are -// personalized before injection or upload review. requireAnnounce keeps -// source-only specs inactive until the user configured a personal announce URL. -type trackerUploadTorrentSpec struct { - source string - defaultAnnounce string - useMyAnnounce bool - requireAnnounce bool +// PrepareTrackerUploadTorrent prepares tracker-specific torrent metainfo using the default registry. +func PrepareTrackerUploadTorrent(meta api.UploadSubject, dbPath string, tracker string, trackerConfig config.TrackerConfig) (api.UploadSubject, error) { + return PrepareTrackerUploadTorrentWithRegistry(meta, dbPath, tracker, trackerConfig, nil) } -var trackerUploadTorrentSpecs = map[string]trackerUploadTorrentSpec{ - "ACM": {source: "AsianCinema"}, - "ANT": {source: "ANT"}, - "AR": {source: "AlphaRatio"}, - "ASC": {source: "ASC"}, - "AZ": {source: "AvistaZ", defaultAnnounce: "https://tracker.avistaz.to/announce"}, - "BHD": {source: "BHD"}, - "BHDTV": {source: "BIT-HDTV", useMyAnnounce: true}, - "BJS": {source: "BJ"}, - "BT": {source: "BT"}, - "BTN": {source: "BTN", requireAnnounce: true}, - "CZ": {source: "CinemaZ", defaultAnnounce: "https://tracker.cinemaz.to/announce"}, - "CZT": {source: "CzT"}, - "DC": {source: "DigitalCore.club"}, - "FF": {source: "FunFile"}, - "FL": {source: "FL"}, - "GPW": {source: "GreatPosterWall"}, - "HDB": {source: "HDBits"}, - "HDS": {source: "HD-Space"}, - "HDT": {source: "hd-torrents.org"}, - "IS": {source: "https://immortalseed.me"}, - "MTV": {source: "MTV"}, - "NBL": {source: "NBL"}, - "PHD": {source: "PrivateHD", defaultAnnounce: "https://tracker.privatehd.to/announce"}, - "PTP": {source: "PTP"}, - "PTS": {source: "[www.ptskit.org] PTSKIT"}, - "RTF": {source: "sunshine"}, - "THR": {source: "[https://www.torrenthr.org] TorrentHR.org"}, - "TL": {source: "TorrentLeech.org"}, - "TOS": {source: "TheOldSchool"}, - "TVC": {source: "TVCHAOS"}, -} - -func PrepareTrackerUploadTorrent(meta api.PreparedMetadata, dbPath string, tracker string, trackerConfig config.TrackerConfig) (api.PreparedMetadata, error) { - source, announce, ok := trackerUploadTorrentFields(tracker, trackerConfig) +// PrepareTrackerUploadTorrentWithRegistry writes a release-scoped personalized +// torrent and returns a copied subject pointing at it. Missing policy, base +// torrent, or insufficient path context leaves the input unchanged; other +// artifact failures are returned. +func PrepareTrackerUploadTorrentWithRegistry( + meta api.UploadSubject, + dbPath string, + tracker string, + trackerConfig config.TrackerConfig, + registry *Registry, +) (api.UploadSubject, error) { + source, announce, ok := trackerUploadTorrentFieldsWithRegistry(tracker, trackerConfig, registry) if !ok { return meta, nil } @@ -74,24 +46,58 @@ func PrepareTrackerUploadTorrent(meta api.PreparedMetadata, dbPath string, track if isUploadTorrentNotFound(err) { return meta, nil } - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } artifactPath, err := ResolveTrackerTorrentArtifactPath(meta, dbPath, tracker) if err != nil { if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { return meta, nil } - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } if err := WritePersonalizedTorrent(basePath, artifactPath, announce, "", source); err != nil { - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } meta.TorrentPath = artifactPath return meta, nil } -func PrepareDryRunInjectionTorrent(meta api.PreparedMetadata, dbPath string, tracker string, trackerConfig config.TrackerConfig) (api.PreparedMetadata, error) { - source, announce, ok := trackerUploadTorrentFields(tracker, trackerConfig) +func trackerUploadTorrentFieldsWithRegistry(tracker string, trackerConfig config.TrackerConfig, registry *Registry) (string, string, bool) { + if owned, ok := registry.LookupUploadArtifactPolicy(tracker); ok { + return uploadArtifactFields(owned, trackerConfig) + } + return trackerUploadTorrentFields(tracker, trackerConfig) +} + +func uploadArtifactFields(policy UploadArtifactPolicy, trackerConfig config.TrackerConfig) (string, string, bool) { + announce := strings.TrimSpace(trackerConfig.AnnounceURL) + if policy.UseMyAnnounce { + announce = strings.TrimSpace(trackerConfig.MyAnnounceURL) + } + if announce == "" { + announce = policy.DefaultAnnounce + } + if policy.RequireAnnounce && announce == "" { + return "", "", false + } + source := strings.TrimSpace(policy.Source) + return source, announce, source != "" || announce != "" +} + +// PrepareDryRunInjectionTorrent prepares a dry-run torrent artifact using the default registry. +func PrepareDryRunInjectionTorrent(meta api.UploadSubject, dbPath string, tracker string, trackerConfig config.TrackerConfig) (api.UploadSubject, error) { + return PrepareDryRunInjectionTorrentWithRegistry(meta, dbPath, tracker, trackerConfig, nil) +} + +// PrepareDryRunInjectionTorrentWithRegistry prepares a dry-run torrent artifact using registry policy. +func PrepareDryRunInjectionTorrentWithRegistry( + meta api.UploadSubject, + dbPath string, + tracker string, + trackerConfig config.TrackerConfig, + registry *Registry, +) (api.UploadSubject, error) { + source, announce, ok := trackerUploadTorrentFieldsWithRegistry(tracker, trackerConfig, registry) if !ok { source = strings.ToUpper(strings.TrimSpace(tracker)) announce = strings.TrimSpace(trackerConfig.AnnounceURL) @@ -108,17 +114,17 @@ func PrepareDryRunInjectionTorrent(meta api.PreparedMetadata, dbPath string, tra if isUploadTorrentNotFound(err) { return meta, nil } - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } artifactPath, err := ResolveTrackerTorrentArtifactPath(meta, dbPath, tracker) if err != nil { if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { return meta, nil } - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } if err := WritePersonalizedTorrent(basePath, artifactPath, announce, "", source); err != nil { - return api.PreparedMetadata{}, err + return api.UploadSubject{}, err } meta.TorrentPath = artifactPath return meta, nil @@ -126,28 +132,19 @@ func PrepareDryRunInjectionTorrent(meta api.PreparedMetadata, dbPath string, tra func trackerUploadTorrentFields(tracker string, trackerConfig config.TrackerConfig) (string, string, bool) { name := strings.ToUpper(strings.TrimSpace(tracker)) - spec, ok := trackerUploadTorrentSpecs[name] - if !ok { - return "", "", false - } announce := strings.TrimSpace(trackerConfig.AnnounceURL) - if spec.useMyAnnounce { - announce = strings.TrimSpace(trackerConfig.MyAnnounceURL) - } if announce == "" { - announce = spec.defaultAnnounce - } - if spec.requireAnnounce && announce == "" { - return "", "", false + announce = strings.TrimSpace(trackerConfig.MyAnnounceURL) } - source := strings.TrimSpace(spec.source) + source := name if source == "" && announce == "" { return "", "", false } - return source, announce, true + return source, announce, source != "" || announce != "" } -func ResolveTrackerTorrentArtifactPath(meta api.PreparedMetadata, dbPath string, tracker string) (string, error) { +// ResolveTrackerTorrentArtifactPath returns the local torrent artifact path for tracker. +func ResolveTrackerTorrentArtifactPath(meta api.UploadSubject, dbPath string, tracker string) (string, error) { if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { return "", errors.New("trackers: tracker torrent path requires db path and source path") } @@ -156,7 +153,7 @@ func ResolveTrackerTorrentArtifactPath(meta api.PreparedMetadata, dbPath string, if err != nil { return "", fmt.Errorf("trackers: %w", err) } - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "", fmt.Errorf("trackers: %w", err) } @@ -169,7 +166,11 @@ func ResolveTrackerTorrentArtifactPath(meta api.PreparedMetadata, dbPath string, return filepath.Join(tmpDir, "["+name+"]."+base+".torrent"), nil } -func ResolveUploadTorrentPath(meta api.PreparedMetadata, dbPath string) (string, error) { +// ResolveUploadTorrentPath selects TorrentPath, ClientTorrentPath, SourcePath, +// then the release-scoped default. A non-default candidate is copied to the +// release-scoped path with tracker fields removed when it can be decoded; an +// existing candidate already at that path is returned as-is. +func ResolveUploadTorrentPath(meta api.UploadSubject, dbPath string) (string, error) { cleanPath, cleanPathOK := uploadTorrentCleanPath(meta, dbPath) candidates := []string{ strings.TrimSpace(meta.TorrentPath), @@ -182,6 +183,9 @@ func ResolveUploadTorrentPath(meta api.PreparedMetadata, dbPath string) (string, } if info, err := os.Stat(candidate); err == nil && !info.IsDir() { if cleanPathOK { + if strings.EqualFold(filepath.Clean(candidate), filepath.Clean(cleanPath)) { + return cleanPath, nil + } err := WriteUploadTorrent(candidate, cleanPath) if err == nil { return cleanPath, nil @@ -197,7 +201,7 @@ func ResolveUploadTorrentPath(meta api.PreparedMetadata, dbPath string) (string, if strings.TrimSpace(dbPath) != "" && strings.TrimSpace(meta.SourcePath) != "" { tmpRoot, err := db.Subdir(dbPath, "tmp") if err == nil { - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err == nil { guessed := filepath.Join(tmpDir, base+".torrent") if info, err := os.Stat(guessed); err == nil && !info.IsDir() { @@ -213,7 +217,7 @@ func ResolveUploadTorrentPath(meta api.PreparedMetadata, dbPath string) (string, return "", fmt.Errorf("trackers: %w", errUploadTorrentNotFound) } -func uploadTorrentCleanPath(meta api.PreparedMetadata, dbPath string) (string, bool) { +func uploadTorrentCleanPath(meta api.UploadSubject, dbPath string) (string, bool) { if strings.TrimSpace(dbPath) == "" || strings.TrimSpace(meta.SourcePath) == "" { return "", false } @@ -221,7 +225,7 @@ func uploadTorrentCleanPath(meta api.PreparedMetadata, dbPath string) (string, b if err != nil { return "", false } - tmpDir, base, err := paths.ReleaseTempDir(tmpRoot, meta, meta.SourcePath) + tmpDir, base, err := paths.ReleaseTempDirFor(tmpRoot, meta.SourcePath, meta.Release) if err != nil { return "", false } @@ -239,6 +243,9 @@ func isUploadTorrentNotFound(err error) bool { return errors.Is(err, errUploadTorrentNotFound) } +// WriteUploadTorrent validates sourcePath, removes announce, node, URL-list, and +// source fields, applies canonical creator/comment values, and atomically writes +// outputPath with mode 0600. func WriteUploadTorrent(sourcePath string, outputPath string) error { torrentMeta, err := metainfo.LoadFromFile(sourcePath) if err != nil { @@ -251,6 +258,9 @@ func WriteUploadTorrent(sourcePath string, outputPath string) error { return writeTorrentMeta(*torrentMeta, outputPath, "upload torrent") } +// WritePersonalizedTorrent strips inherited tracker fields, applies the supplied +// announce, comment, and source values plus the canonical creator, and atomically +// writes outputPath with mode 0600. func WritePersonalizedTorrent(sourcePath string, outputPath string, announceURL string, comment string, source string) error { torrentMeta, err := metainfo.LoadFromFile(sourcePath) if err != nil { diff --git a/internal/trackers/upload_artifact_test.go b/internal/trackers/upload_artifact_test.go index 1d3ada8e..3d6005fb 100644 --- a/internal/trackers/upload_artifact_test.go +++ b/internal/trackers/upload_artifact_test.go @@ -39,7 +39,7 @@ func TestResolveUploadTorrentPathWritesCleanBaseCopy(t *testing.T) { InfoBytes: testInfoBytes(t, "BHD"), }) - got, err := ResolveUploadTorrentPath(api.PreparedMetadata{ + got, err := ResolveUploadTorrentPath(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: dirtyTorrentPath, }, filepath.Join(tmp, "state", "upbrr.db")) @@ -95,7 +95,7 @@ func TestResolveUploadTorrentPathSamePathRewriteFailurePreservesGuessedTorrent(t t.Fatalf("write source: %v", err) } dbPath := filepath.Join(tmp, "state", "upbrr.db") - meta := api.PreparedMetadata{SourcePath: sourcePath} + meta := api.UploadSubject{SourcePath: sourcePath} guessed, ok := uploadTorrentCleanPath(meta, dbPath) if !ok { t.Fatal("expected clean upload torrent path") @@ -139,7 +139,7 @@ func TestResolveUploadTorrentPathFallsBackToExplicitCandidateWhenCleanCopyInvali t.Fatalf("write invalid torrent: %v", err) } - got, err := ResolveUploadTorrentPath(api.PreparedMetadata{ + got, err := ResolveUploadTorrentPath(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: invalidTorrentPath, }, filepath.Join(tmp, "state", "upbrr.db")) @@ -218,7 +218,7 @@ func TestResolveUploadTorrentPathWithoutCleanTargetLeavesOriginalUnchanged(t *te InfoBytes: testInfoBytes(t, ""), }) - got, err := ResolveUploadTorrentPath(api.PreparedMetadata{TorrentPath: torrentPath}, "") + got, err := ResolveUploadTorrentPath(api.UploadSubject{TorrentPath: torrentPath}, "") if err != nil { t.Fatalf("resolve upload torrent: %v", err) } @@ -293,10 +293,10 @@ func TestPrepareTrackerUploadTorrentCreatesSpecificArtifact(t *testing.T) { }) dbPath := filepath.Join(tmp, "state", "upbrr.db") - meta, err := PrepareTrackerUploadTorrent(api.PreparedMetadata{ + meta, err := PrepareTrackerUploadTorrentWithRegistry(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: baseTorrentPath, - }, dbPath, "HDB", config.TrackerConfig{AnnounceURL: "https://new.example/announce"}) + }, dbPath, "HDB", config.TrackerConfig{AnnounceURL: "https://new.example/announce"}, hdbArtifactRegistry(t)) if err != nil { t.Fatalf("prepare tracker torrent: %v", err) } @@ -310,7 +310,7 @@ func TestPrepareTrackerUploadTorrentCreatesSpecificArtifact(t *testing.T) { } assertInfoSource(t, artifact, "HDBits") - cleanBase, ok := uploadTorrentCleanPath(api.PreparedMetadata{SourcePath: sourcePath, TorrentPath: baseTorrentPath}, dbPath) + cleanBase, ok := uploadTorrentCleanPath(api.UploadSubject{SourcePath: sourcePath, TorrentPath: baseTorrentPath}, dbPath) if !ok { t.Fatal("expected clean base path") } @@ -332,10 +332,18 @@ func TestPrepareTrackerUploadTorrentUsesDefaultAnnounce(t *testing.T) { baseTorrentPath := filepath.Join(tmp, "base.torrent") writeTestMetaInfo(t, baseTorrentPath, metainfo.MetaInfo{InfoBytes: testInfoBytes(t, "")}) - meta, err := PrepareTrackerUploadTorrent(api.PreparedMetadata{ + registry := NewRegistry() + if err := registry.RegisterDescriptor(Descriptor{ + Name: "AZ", + Definition: stubDefinition{name: "AZ"}, + UploadArtifact: &UploadArtifactPolicy{Source: "AvistaZ", DefaultAnnounce: "https://tracker.avistaz.to/announce"}, + }); err != nil { + t.Fatalf("register AZ artifact policy: %v", err) + } + meta, err := PrepareTrackerUploadTorrentWithRegistry(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: baseTorrentPath, - }, filepath.Join(tmp, "state", "upbrr.db"), "AZ", config.TrackerConfig{}) + }, filepath.Join(tmp, "state", "upbrr.db"), "AZ", config.TrackerConfig{}, registry) if err != nil { t.Fatalf("prepare tracker torrent: %v", err) } @@ -357,10 +365,10 @@ func TestPrepareTrackerUploadTorrentUsesBTNAnnounceURL(t *testing.T) { baseTorrentPath := filepath.Join(tmp, "base.torrent") writeTestMetaInfo(t, baseTorrentPath, metainfo.MetaInfo{InfoBytes: testInfoBytes(t, "")}) - meta, err := PrepareTrackerUploadTorrent(api.PreparedMetadata{ + meta, err := PrepareTrackerUploadTorrentWithRegistry(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: baseTorrentPath, - }, filepath.Join(tmp, "state", "upbrr.db"), "BTN", config.TrackerConfig{AnnounceURL: "https://tracker.btn.example/announce/passkey"}) + }, filepath.Join(tmp, "state", "upbrr.db"), "BTN", config.TrackerConfig{AnnounceURL: "https://tracker.btn.example/announce/passkey"}, btnArtifactRegistry(t)) if err != nil { t.Fatalf("prepare tracker torrent: %v", err) } @@ -386,11 +394,11 @@ func TestPrepareTrackerUploadTorrentSkipsBTNWithoutAnnounceURL(t *testing.T) { writeTestMetaInfo(t, baseTorrentPath, metainfo.MetaInfo{InfoBytes: testInfoBytes(t, "")}) dbPath := filepath.Join(tmp, "state", "upbrr.db") - meta := api.PreparedMetadata{ + meta := api.UploadSubject{ SourcePath: sourcePath, TorrentPath: baseTorrentPath, } - got, err := PrepareTrackerUploadTorrent(meta, dbPath, "BTN", config.TrackerConfig{}) + got, err := PrepareTrackerUploadTorrentWithRegistry(meta, dbPath, "BTN", config.TrackerConfig{}, btnArtifactRegistry(t)) if err != nil { t.Fatalf("prepare tracker torrent: %v", err) } @@ -409,8 +417,8 @@ func TestPrepareTrackerUploadTorrentSkipsBTNWithoutAnnounceURL(t *testing.T) { func TestPrepareTrackerUploadTorrentNoSpecLeavesMetaUnchanged(t *testing.T) { t.Parallel() - meta := api.PreparedMetadata{TorrentPath: filepath.Join(t.TempDir(), "base.torrent")} - got, err := PrepareTrackerUploadTorrent(meta, "", "BTN", config.TrackerConfig{}) + meta := api.UploadSubject{TorrentPath: filepath.Join(t.TempDir(), "base.torrent")} + got, err := PrepareTrackerUploadTorrent(meta, "", "UNKNOWN", config.TrackerConfig{}) if err != nil { t.Fatalf("prepare tracker torrent: %v", err) } @@ -419,6 +427,34 @@ func TestPrepareTrackerUploadTorrentNoSpecLeavesMetaUnchanged(t *testing.T) { } } +func btnArtifactRegistry(t *testing.T) *Registry { + t.Helper() + registry := NewRegistry() + policy := &UploadArtifactPolicy{Source: "BTN", RequireAnnounce: true} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "BTN", + Definition: stubDefinition{name: "BTN"}, + UploadArtifact: policy, + }); err != nil { + t.Fatalf("register BTN artifact policy: %v", err) + } + return registry +} + +func hdbArtifactRegistry(t *testing.T) *Registry { + t.Helper() + registry := NewRegistry() + policy := &UploadArtifactPolicy{Source: "HDBits"} + if err := registry.RegisterDescriptor(Descriptor{ + Name: "HDB", + Definition: stubDefinition{name: "HDB"}, + UploadArtifact: policy, + }); err != nil { + t.Fatalf("register HDB artifact policy: %v", err) + } + return registry +} + func TestPrepareDryRunInjectionTorrentCreatesGenericTrackerArtifact(t *testing.T) { t.Parallel() @@ -430,7 +466,7 @@ func TestPrepareDryRunInjectionTorrentCreatesGenericTrackerArtifact(t *testing.T baseTorrentPath := filepath.Join(tmp, "base.torrent") writeTestMetaInfo(t, baseTorrentPath, metainfo.MetaInfo{InfoBytes: testInfoBytes(t, "")}) - meta, err := PrepareDryRunInjectionTorrent(api.PreparedMetadata{ + meta, err := PrepareDryRunInjectionTorrent(api.UploadSubject{ SourcePath: sourcePath, TorrentPath: baseTorrentPath, }, filepath.Join(tmp, "state", "upbrr.db"), "LUME", config.TrackerConfig{AnnounceURL: "https://luminarr.me/announce/passkey"}) @@ -453,7 +489,7 @@ func TestResolveTrackerTorrentArtifactPathPrefixesTrackerName(t *testing.T) { tmp := t.TempDir() sourcePath := filepath.Join(tmp, "Example.Movie.2026.BluRay.1080p.DTS.x264-GRP.mkv") - got, err := ResolveTrackerTorrentArtifactPath(api.PreparedMetadata{SourcePath: sourcePath}, filepath.Join(tmp, "state", "upbrr.db"), "MTV") + got, err := ResolveTrackerTorrentArtifactPath(api.UploadSubject{SourcePath: sourcePath}, filepath.Join(tmp, "state", "upbrr.db"), "MTV") if err != nil { t.Fatalf("resolve tracker torrent artifact: %v", err) } diff --git a/internal/trackers/upload_content.go b/internal/trackers/upload_content.go new file mode 100644 index 00000000..dd02ec3d --- /dev/null +++ b/internal/trackers/upload_content.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package trackers + +import "github.com/autobrr/upbrr/pkg/api" + +// UploadContentMode identifies the shared content object a tracker consumes +// before its tracker-local upload adapter is invoked. +type UploadContentMode string + +const ( + // UploadContentModeNone bypasses shared description and image preparation. + UploadContentModeNone UploadContentMode = "none" + // UploadContentModeScreenshots consumes shared screenshot and menu-image assets only. + UploadContentModeScreenshots UploadContentMode = "screenshots" + // UploadContentModeDescription consumes the aggregate description and image object. + UploadContentModeDescription UploadContentMode = "description" +) + +// Valid reports whether mode is one of the supported semantic workflow shapes. +func (m UploadContentMode) Valid() bool { + switch m { + case UploadContentModeNone, UploadContentModeScreenshots, UploadContentModeDescription: + return true + default: + return false + } +} + +// UsesDescription reports whether shared description construction is required. +func (m UploadContentMode) UsesDescription() bool { return m == UploadContentModeDescription } + +// UsesImages reports whether shared screenshot or menu-image preparation is required. +func (m UploadContentMode) UsesImages() bool { + return m == UploadContentModeScreenshots || m == UploadContentModeDescription +} + +// FailureReasonCode returns the canonical eligibility reason for a failed +// associated content object. +func (m UploadContentMode) FailureReasonCode() api.TrackerEligibilityReasonCode { + switch m { + case UploadContentModeNone: + return "" + case UploadContentModeScreenshots: + return api.TrackerEligibilityScreenshotPreparationFailed + case UploadContentModeDescription: + return api.TrackerEligibilityDescriptionPreparationFailed + default: + return "" + } +} + +// UploadContentModeProvider declares a tracker's shared upload-content workflow. +type UploadContentModeProvider interface { + // UploadContentMode returns the tracker-owned semantic content mode. + UploadContentMode() UploadContentMode +} diff --git a/internal/webserver/app_routes.go b/internal/webserver/app_routes.go index 7c54cc2b..cead2ddd 100644 --- a/internal/webserver/app_routes.go +++ b/internal/webserver/app_routes.go @@ -11,17 +11,28 @@ import ( "path/filepath" "strings" - "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/pathutil" + pathutil "github.com/autobrr/upbrr/internal/pathing" "github.com/autobrr/upbrr/internal/services/trackericon" - "github.com/autobrr/upbrr/internal/trackerauth" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" "github.com/autobrr/upbrr/pkg/api" ) -// cookieImportRequestEnvelopeMaxBytes leaves JSON envelope headroom while the -// tracker auth importer enforces the shared raw cookie content limit. -const cookieImportRequestEnvelopeMaxBytes = trackerauth.MaxCookieImportContentBytes*6 + 64*1024 +const ( + // JSON can encode one raw byte as a six-byte escape. These route caps leave + // fixed field headroom while downstream importers enforce decoded limits. + cookieImportRequestEnvelopeMaxBytes = trackerauth.MaxCookieImportContentBytes*6 + 64*1024 + configImportRequestEnvelopeMaxBytes = configImportMaxBytes*6 + 64*1024 +) + +func nonNilAppList[T any](values []T) []T { + if values == nil { + return []T{} + } + return values +} +// registerAppRoutes installs authenticated browser operations and their +// request-shape adapters on mux. func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/ListTrackerAuthCapabilities", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { if r.Method != http.MethodPost { @@ -33,7 +44,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/GetTrackerAuthStatus", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { @@ -155,57 +166,6 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusOK, value) })) - mux.HandleFunc("/api/app/BrowseFile", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - if !s.nativeBrowseAvailable(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "native browse is only available from localhost web sessions"}) - return - } - value, err := s.picker.BrowseFile() - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, value) - })) - - mux.HandleFunc("/api/app/BrowseImageFiles", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - if !s.nativeBrowseAvailable(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "native browse is only available from localhost web sessions"}) - return - } - value, err := s.picker.BrowseImageFiles() - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, value) - })) - - mux.HandleFunc("/api/app/BrowseFolder", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - if !s.nativeBrowseAvailable(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "native browse is only available from localhost web sessions"}) - return - } - value, err := s.picker.BrowseFolder() - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, value) - })) - mux.HandleFunc("/api/app/DetectDiscType", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct{ Path string } if err := decodeJSON(r, &req); err != nil { @@ -222,18 +182,31 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/FetchMetadata", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { + CorrelationID string Path string SourceLookupURL string Overrides api.ExternalIDOverrides NameOverrides api.ReleaseNameOverrides - Trackers []string + Playlist api.PlaylistInstruction ConfirmBDMVRescan bool } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.FetchMetadata(current.ID, req.Path, req.SourceLookupURL, req.Overrides, req.NameOverrides, req.Trackers, req.ConfirmBDMVRescan) + value, err := s.backend.FetchMetadata( + r.Context(), + current.ID, + metadataPreparationRequest{ + CorrelationID: req.CorrelationID, + Path: req.Path, + SourceLookupURL: req.SourceLookupURL, + Overrides: req.Overrides, + NameOverrides: req.NameOverrides, + Playlist: req.Playlist, + ConfirmBDMVRescan: req.ConfirmBDMVRescan, + }, + ) if err != nil { writeAppError(w, err) return @@ -243,35 +216,31 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/ResetMetadata", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { + CorrelationID string Path string SourceLookupURL string Overrides api.ExternalIDOverrides NameOverrides api.ReleaseNameOverrides - Trackers []string + Playlist api.PlaylistInstruction ConfirmBDMVRescan bool } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.ResetMetadata(current.ID, req.Path, req.SourceLookupURL, req.Overrides, req.NameOverrides, req.Trackers, req.ConfirmBDMVRescan) - if err != nil { - writeAppError(w, err) - return - } - writeJSON(w, http.StatusOK, value) - })) - - mux.HandleFunc("/api/app/SelectBlurayCandidate", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - var req struct { - Path string - ReleaseID string - } - if err := decodeJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - value, err := s.backend.SelectBlurayCandidate(req.Path, req.ReleaseID) + value, err := s.backend.ResetMetadata( + r.Context(), + current.ID, + metadataPreparationRequest{ + CorrelationID: req.CorrelationID, + Path: req.Path, + SourceLookupURL: req.SourceLookupURL, + Overrides: req.Overrides, + NameOverrides: req.NameOverrides, + Playlist: req.Playlist, + ConfirmBDMVRescan: req.ConfirmBDMVRescan, + }, + ) if err != nil { writeAppError(w, err) return @@ -279,20 +248,23 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusOK, value) })) - mux.HandleFunc("/api/app/CheckDupes", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { + mux.HandleFunc("/api/app/SelectBlurayCandidate", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { + CorrelationID string Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - Trackers []string + ReleaseID string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.CheckDupes(req.Path, req.Overrides, req.NameOverrides, req.Trackers) + value, err := s.backend.SelectBlurayCandidate(r.Context(), current.ID, blurayCandidateSelectionRequest{ + CorrelationID: req.CorrelationID, + Path: req.Path, + ReleaseID: req.ReleaseID, + }) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -312,7 +284,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { } value, err := s.backend.FetchPreparation(current.ID, req.Path, req.Overrides, req.NameOverrides, req.Trackers, req.IgnoreDupesFor) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -320,14 +292,12 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/FetchTrackerDryRun", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + DupeJobID string + Release api.ReleaseRef Trackers []string IgnoreDupesFor []string QuestionnaireAnswers map[string]map[string]string DescriptionGroups []api.DescriptionBuilderGroup - Debug bool NoSeed bool RunLogLevel string } @@ -335,9 +305,20 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.FetchTrackerDryRun(current.ID, req.Path, req.Overrides, req.NameOverrides, req.Trackers, req.IgnoreDupesFor, req.QuestionnaireAnswers, req.DescriptionGroups, req.Debug, req.NoSeed, req.RunLogLevel) + value, err := s.backend.FetchTrackerDryRun( + r.Context(), + current.ID, + req.DupeJobID, + req.Release, + req.Trackers, + req.IgnoreDupesFor, + req.QuestionnaireAnswers, + req.DescriptionGroups, + req.NoSeed, + req.RunLogLevel, + ) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -345,19 +326,16 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/FetchDescriptionBuilder", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - Trackers []string - IgnoreDupesFor []string + Release api.ReleaseRef + Trackers []string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.FetchDescriptionBuilder(req.Path, req.Overrides, req.NameOverrides, req.Trackers, req.IgnoreDupesFor) + value, err := s.backend.FetchDescriptionBuilder(req.Release, req.Trackers) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -371,7 +349,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { } value, err := s.backend.RenderDescription(req.Raw) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -379,20 +357,18 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/SaveDescriptionOverride", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - GroupKey string - Raw string - Trackers []string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef + GroupKey string + Raw string + Trackers []string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.SaveDescriptionOverride(req.Path, req.GroupKey, req.Raw, req.Trackers, req.Overrides, req.NameOverrides) + value, err := s.backend.SaveDescriptionOverride(req.Release, req.GroupKey, req.Raw, req.Trackers) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -404,40 +380,9 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.DiscoverPlaylists(req.Path) + value, err := s.backend.DiscoverPlaylists(r.Context(), req.Path) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, value) - })) - - mux.HandleFunc("/api/app/SavePlaylistSelection", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - var req struct { - Path string - Playlists []string - UseAll bool - } - if err := decodeJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - if err := s.backend.SavePlaylistSelection(req.Path, req.Playlists, req.UseAll); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) - })) - - mux.HandleFunc("/api/app/LoadPlaylistSelection", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { - var req struct{ Path string } - if err := decodeJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - value, err := s.backend.LoadPlaylistSelection(req.Path) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -472,17 +417,15 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/FetchScreenshotPlan", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.FetchScreenshotPlan(req.Path, req.Overrides, req.NameOverrides) + value, err := s.backend.FetchScreenshotPlan(req.Release) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -490,19 +433,17 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/GenerateScreenshots", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - Selections []api.ScreenshotSelection - Purpose api.ScreenshotPurpose + Release api.ReleaseRef + Selections []api.ScreenshotSelection + Purpose api.ScreenshotPurpose } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.GenerateScreenshots(req.Path, req.Overrides, req.NameOverrides, req.Selections, req.Purpose) + value, err := s.backend.GenerateScreenshots(req.Release, req.Selections, req.Purpose) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -510,18 +451,16 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/PreviewScreenshotFrame", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef TimestampSeconds float64 } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.PreviewScreenshotFrame(req.Path, req.Overrides, req.NameOverrides, req.TimestampSeconds) + value, err := s.backend.PreviewScreenshotFrame(req.Release, req.TimestampSeconds) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -529,17 +468,15 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/DeleteScreenshot", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - ImagePath string + Release api.ReleaseRef + ImagePath string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.DeleteScreenshot(req.Path, req.Overrides, req.NameOverrides, req.ImagePath); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.DeleteScreenshot(req.Release, req.ImagePath); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -547,17 +484,15 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/DeleteTrackerImageURL", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - URL string + Release api.ReleaseRef + URL string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.DeleteTrackerImageURL(req.Path, req.Overrides, req.NameOverrides, req.URL); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.DeleteTrackerImageURL(req.Release, req.URL); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -565,17 +500,15 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/SaveFinalScreenshotSelections", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - Images []api.ScreenshotImage + Release api.ReleaseRef + Images []api.ScreenshotImage } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.SaveFinalScreenshotSelections(req.Path, req.Overrides, req.NameOverrides, req.Images); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.SaveFinalScreenshotSelections(req.Release, req.Images); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -583,10 +516,8 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/ImportMenuImages", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - Paths []string + Release api.ReleaseRef + Paths []string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) @@ -606,82 +537,47 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.ImportMenuImages(req.Path, req.Overrides, req.NameOverrides, importPaths); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.ImportMenuImages(req.Release, importPaths); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) })) - mux.HandleFunc("/api/app/StartDVDMenuCapture", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { + mux.HandleFunc("/api/app/CaptureDVDMenus", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.StartDVDMenuCapture(r.Context(), current.ID, req.Path, req.Overrides, req.NameOverrides) + value, err := s.backend.CaptureDVDMenus(r.Context(), req.Release) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, value) - })) - - mux.HandleFunc("/api/app/GetDVDMenuCaptureSnapshot", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { - var req struct{ JobID string } - if err := decodeJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - value, err := s.backend.GetDVDMenuCaptureSnapshot(current.ID, req.JobID) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) })) - mux.HandleFunc("/api/app/CancelDVDMenuCapture", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - var req struct{ JobID string } - if err := decodeJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - if err := s.backend.CancelDVDMenuCapture(current.ID, req.JobID); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) - })) - mux.HandleFunc("/api/app/ListDVDMenuScreenshots", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.ListDVDMenuScreenshots(req.Path, req.Overrides, req.NameOverrides) + value, err := s.backend.ListDVDMenuScreenshots(req.Release) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/DeleteDVDMenuScreenshot", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { @@ -690,17 +586,15 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { return } var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides - ImagePath string + Release api.ReleaseRef + ImagePath string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.DeleteDVDMenuScreenshot(req.Path, req.Overrides, req.NameOverrides, req.ImagePath); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.DeleteDVDMenuScreenshot(req.Release, req.ImagePath); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -722,45 +616,40 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/ListUploadCandidates", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.ListUploadCandidates(req.Path, req.Overrides, req.NameOverrides) + value, err := s.backend.ListUploadCandidates(req.Release) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/ListUploadedImages", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.ListUploadedImages(req.Path, req.Overrides, req.NameOverrides) + value, err := s.backend.ListUploadedImages(req.Release) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) - mux.HandleFunc("/api/app/UploadImages", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { + mux.HandleFunc("/api/app/UploadImages", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + CorrelationID string + Release api.ReleaseRef Trackers []string Host string Images []api.ScreenshotImage @@ -769,9 +658,9 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.UploadImages(req.Path, req.Overrides, req.NameOverrides, req.Trackers, req.Host, req.Images) + value, err := s.backend.UploadImages(r.Context(), current.ID, req.CorrelationID, req.Release, req.Trackers, req.Host, req.Images) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -779,7 +668,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/DeleteUploadedImage", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { var req struct { - Path string + Release api.ReleaseRef ImagePath string Host string } @@ -787,8 +676,8 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if err := s.backend.DeleteUploadedImage(req.Path, req.ImagePath, req.Host); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + if err := s.backend.DeleteUploadedImage(req.Release, req.ImagePath, req.Host); err != nil { + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -852,9 +741,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } - // Allow extra headroom for JSON wrapping (FileName, escaping) - // beyond the raw file-content limit enforced by the importer. - r.Body = http.MaxBytesReader(w, r.Body, configImportMaxBytes+1024*1024) + r.Body = http.MaxBytesReader(w, r.Body, configImportRequestEnvelopeMaxBytes) var req struct { FileName string FileContent string @@ -871,8 +758,8 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusOK, map[string]any{"result": result, "warnings": warnings}) })) - mux.HandleFunc("/api/app/ListKnownTrackers", s.requireSession(func(w http.ResponseWriter, _ *http.Request, _ session) { - value, err := s.backend.ListKnownTrackers() + mux.HandleFunc("/api/app/ListTrackerCatalog", s.requireSession(func(w http.ResponseWriter, _ *http.Request, _ session) { + value, err := s.backend.ListTrackerCatalog() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -895,7 +782,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/GetHistoryOverview", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { @@ -945,7 +832,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/StartLogStream", s.requireSession(func(w http.ResponseWriter, _ *http.Request, current session) { @@ -976,7 +863,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, value) + writeJSON(w, http.StatusOK, nonNilAppList(value)) })) mux.HandleFunc("/api/app/UpdateLogExclusions", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { @@ -994,18 +881,19 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/app/StartDupeCheck", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef Trackers []string + CorrelationID string } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.StartDupeCheck(r.Context(), current.ID, req.Path, req.Overrides, req.NameOverrides, req.Trackers) + value, err := s.backend.StartDupeCheck( + r.Context(), current.ID, req.Release, req.Trackers, req.CorrelationID, + ) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -1022,7 +910,7 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { return } if err := s.backend.CancelDupeCheck(current.ID, req.JobID); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) @@ -1036,22 +924,20 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { } value, err := s.backend.GetDupeCheckSnapshot(current.ID, req.JobID) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) })) - mux.HandleFunc("/api/app/StartTrackerUpload", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { + mux.HandleFunc("/api/app/ReviewTrackerUpload", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { var req struct { - Path string - Overrides api.ExternalIDOverrides - NameOverrides api.ReleaseNameOverrides + Release api.ReleaseRef Trackers []string IgnoreDupesFor []string + RuleAuthorizations []api.RuleAuthorization QuestionnaireAnswers map[string]map[string]string DescriptionGroups []api.DescriptionBuilderGroup - Debug bool NoSeed bool RunLogLevel string } @@ -1059,11 +945,39 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.StartTrackerUpload(current.ID, req.Path, req.Overrides, req.NameOverrides, req.Trackers, req.IgnoreDupesFor, req.QuestionnaireAnswers, req.DescriptionGroups, req.Debug, req.NoSeed, req.RunLogLevel) + value, err := s.backend.ReviewTrackerUpload( + r.Context(), + current.ID, + req.Release, + req.Trackers, + req.IgnoreDupesFor, + req.RuleAuthorizations, + req.QuestionnaireAnswers, + req.DescriptionGroups, + req.NoSeed, + req.RunLogLevel, + ) if err != nil { + writeAppError(w, err) + return + } + writeJSON(w, http.StatusOK, value) + })) + + mux.HandleFunc("/api/app/StartReviewedTrackerUpload", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { + var req struct { + Token string + CorrelationID string + } + if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } + value, err := s.backend.StartReviewedTrackerUpload(current.ID, req.Token, req.CorrelationID) + if err != nil { + writeAppError(w, err) + return + } writeJSON(w, http.StatusOK, value) })) @@ -1078,21 +992,24 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { return } if err := s.backend.CancelTrackerUpload(current.ID, req.JobID); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) })) mux.HandleFunc("/api/app/RetryFailedTrackerUpload", s.requireSession(func(w http.ResponseWriter, r *http.Request, current session) { - var req struct{ JobID string } + var req struct { + JobID string + CorrelationID string + } if err := decodeJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - value, err := s.backend.RetryFailedTrackerUpload(current.ID, req.JobID) + value, err := s.backend.RetryFailedTrackerUpload(current.ID, req.JobID, req.CorrelationID) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) @@ -1106,12 +1023,21 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { } value, err := s.backend.GetTrackerUploadSnapshot(current.ID, req.JobID) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeAppError(w, err) return } writeJSON(w, http.StatusOK, value) })) + mux.HandleFunc("/api/app/ListJobs", s.requireSession(func(w http.ResponseWriter, _ *http.Request, current session) { + value, err := s.backend.ListJobs(current.ID) + if err != nil { + writeAppError(w, err) + return + } + writeJSON(w, http.StatusOK, nonNilAppList(value)) + })) + mux.HandleFunc("/api/app/GetTrackerIcon", s.requireSession(func(w http.ResponseWriter, r *http.Request, _ session) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) @@ -1129,10 +1055,10 @@ func (s *Server) registerAppRoutes(mux *http.ServeMux) { if s.backend != nil { cfg = s.backend.currentConfig() } - domain, resolvedURL := config.ResolveTrackerDomain(&cfg, req.Domain) - urlToUse := req.URL - if urlToUse == "" { - urlToUse = resolvedURL + domain, urlToUse, err := resolveTrackerIconTarget(req.Domain, req.URL) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return } value, err := trackericon.GetTrackerIcon(r.Context(), cfg.MainSettings.DBPath, domain, urlToUse) if err != nil { @@ -1252,18 +1178,28 @@ func pathWithinBrowseRoots(candidate string, roots []string) bool { return false } +// writeAppError exposes structured operation failures with their safe message +// and hides all unstructured error detail behind a generic internal failure. func writeAppError(w http.ResponseWriter, err error) { - var rescanErr *api.BDMVRescanRequiredError - if errors.As(err, &rescanErr) { - writeJSON(w, http.StatusConflict, map[string]any{ - "error": err.Error(), - "code": api.ErrCodeBDMVRescanRequired, - "source_path": rescanErr.SourcePath, - "selected_playlists": rescanErr.SelectedPlaylists, - "cached_playlists": rescanErr.CachedPlaylists, - "missing_playlists": rescanErr.MissingPlaylists, + if failure, ok := api.AsOperationFailure(err); ok { + status := http.StatusConflict + if failure.Code == api.OperationFailureInvalidInput || failure.Code == api.OperationFailureInvalidSource { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]any{ + "error": failure.Message, + "failure": failure, }) return } - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + failure := api.OperationFailure{ + Code: api.OperationFailureInternal, + Operation: api.OperationKindUnknown, + Message: "The operation could not be completed.", + Recovery: api.OperationRecoveryRetry, + } + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": failure.Message, + "failure": failure, + }) } diff --git a/internal/webserver/app_routes_errors_test.go b/internal/webserver/app_routes_errors_test.go index fddd5c2e..2200281a 100644 --- a/internal/webserver/app_routes_errors_test.go +++ b/internal/webserver/app_routes_errors_test.go @@ -5,22 +5,30 @@ package webserver import ( "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/autobrr/upbrr/pkg/api" ) -func TestWriteAppErrorBDMVRescanRequired(t *testing.T) { +func TestWriteAppErrorBDMVRescanRequiredIsStructuredAndPathFree(t *testing.T) { recorder := httptest.NewRecorder() - writeAppError(recorder, &api.BDMVRescanRequiredError{ + cause := &api.BDMVRescanRequiredError{ SourcePath: `/disc`, SelectedPlaylists: []string{"00002.MPLS", "00001.MPLS"}, CachedPlaylists: []string{"00001.MPLS"}, MissingPlaylists: []string{"00002.MPLS"}, - }) + } + writeAppError(recorder, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureConfirmationRequired, + Operation: api.OperationKindPreparation, + Message: "Blu-ray playlist changes require confirmation before rescanning.", + Recovery: api.OperationRecoveryConfirm, + }, cause)) if recorder.Code != http.StatusConflict { t.Fatalf("expected status 409, got %d", recorder.Code) @@ -30,10 +38,25 @@ func TestWriteAppErrorBDMVRescanRequired(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } - if got := payload["code"]; got != api.ErrCodeBDMVRescanRequired { - t.Fatalf("expected code %q, got %#v", api.ErrCodeBDMVRescanRequired, got) + failure, ok := payload["failure"].(map[string]any) + if !ok { + t.Fatalf("missing failure: %#v", payload) + } + if got := failure["Code"]; got != string(api.OperationFailureConfirmationRequired) { + t.Fatalf("failure code = %#v", got) + } + if _, exists := payload["source_path"]; exists { + t.Fatalf("response exposed source path: %#v", payload) + } +} + +func TestWriteAppErrorUntypedFallbackIsStructuredAndSafe(t *testing.T) { + recorder := httptest.NewRecorder() + writeAppError(recorder, errors.New(`token=secret-value path=C:\private\release`)) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", recorder.Code) } - if got := payload["source_path"]; got != "/disc" { - t.Fatalf("unexpected source path %#v", got) + if strings.Contains(recorder.Body.String(), "secret-value") || strings.Contains(recorder.Body.String(), `C:\private`) { + t.Fatalf("unsafe response: %s", recorder.Body.String()) } } diff --git a/internal/webserver/app_routes_list_test.go b/internal/webserver/app_routes_list_test.go new file mode 100644 index 00000000..1c5f0ec1 --- /dev/null +++ b/internal/webserver/app_routes_list_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "encoding/json" + "testing" +) + +func TestNonNilAppListPreservesJSONArrayShape(t *testing.T) { + t.Parallel() + + payload, err := json.Marshal(nonNilAppList[string](nil)) + if err != nil { + t.Fatalf("marshal normalized list: %v", err) + } + if string(payload) != "[]" { + t.Fatalf("normalized nil list = %s, want []", payload) + } + + values := []string{"one"} + if got := nonNilAppList(values); len(got) != 1 || got[0] != "one" { + t.Fatalf("normalized populated list = %#v, want %#v", got, values) + } +} diff --git a/internal/guishared/application_info.go b/internal/webserver/application_info.go similarity index 94% rename from internal/guishared/application_info.go rename to internal/webserver/application_info.go index 22d206c4..5aa84fd2 100644 --- a/internal/guishared/application_info.go +++ b/internal/webserver/application_info.go @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026, Audionut and the autobrr contributors. // SPDX-License-Identifier: GPL-2.0-or-later -package guishared +package webserver import ( "context" @@ -24,12 +24,13 @@ const ( // CurrentApplicationInfo adds path-free DVD menu capability diagnostics to // the shared application build/runtime information. It bounds the probe to ten -// seconds and returns unavailable status when ctx or provider is nil. +// seconds and returns unavailable status when ctx or provider is nil, including +// an interface containing a typed nil pointer. func CurrentApplicationInfo(ctx context.Context, provider api.DVDMenuCapabilityProvider) api.ApplicationInfo { info := api.CurrentApplicationInfo() info.DVDMenuCapabilityStatus = DVDMenuCapabilityUnavailable info.DVDMenuCapabilityMessage = "DVD menu capture capability could not be checked." - if provider == nil { + if !CapabilityAvailable(provider) { return info } if ctx == nil { diff --git a/internal/guishared/application_info_test.go b/internal/webserver/application_info_test.go similarity index 76% rename from internal/guishared/application_info_test.go rename to internal/webserver/application_info_test.go index 79fd1226..53aaecba 100644 --- a/internal/guishared/application_info_test.go +++ b/internal/webserver/application_info_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026, Audionut and the autobrr contributors. // SPDX-License-Identifier: GPL-2.0-or-later -package guishared +package webserver import ( "context" @@ -17,6 +17,12 @@ type applicationInfoCapabilityProvider struct { err error } +type nilApplicationInfoCapabilityProvider struct{} + +func (*nilApplicationInfoCapabilityProvider) DVDMenuCapability(context.Context) (api.DVDMenuEngineInfo, error) { + panic("typed-nil provider must not be called") +} + func (p applicationInfoCapabilityProvider) DVDMenuCapability(context.Context) (api.DVDMenuEngineInfo, error) { return p.info, p.err } @@ -36,6 +42,16 @@ func TestCurrentApplicationInfoIncludesDVDMenuCapability(t *testing.T) { } } +func TestCurrentApplicationInfoRejectsTypedNilCapability(t *testing.T) { + t.Parallel() + + var provider *nilApplicationInfoCapabilityProvider + info := CurrentApplicationInfo(context.Background(), provider) + if info.DVDMenuCapabilityStatus != DVDMenuCapabilityUnavailable { + t.Fatalf("expected unavailable status, got %q", info.DVDMenuCapabilityStatus) + } +} + func TestCurrentApplicationInfoReportsPathFreeIncompatibleCapability(t *testing.T) { info := CurrentApplicationInfo(context.Background(), applicationInfoCapabilityProvider{ info: api.DVDMenuEngineInfo{ diff --git a/internal/webserver/assets.go b/internal/webserver/assets.go new file mode 100644 index 00000000..f25fef09 --- /dev/null +++ b/internal/webserver/assets.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +// resolveAssets returns an explicit override unchanged. Without one, it selects +// the first embedded or repo-local filesystem containing index.html. +func resolveAssets(override fs.FS) (fs.FS, error) { + if override != nil { + return override, nil + } + if embeddedRoot, err := fs.Sub(embeddedAssets, "assets"); err == nil { + if hasIndex(embeddedRoot) { + return embeddedRoot, nil + } + } + if hasIndex(embeddedAssets) { + return embeddedAssets, nil + } + for _, path := range candidateAssetPaths() { + diskAssets := os.DirFS(path) + if hasIndex(diskAssets) { + return diskAssets, nil + } + } + return nil, errors.New("web assets not found: build webui and retry") +} + +func hasIndex(assets fs.FS) bool { + if assets == nil { + return false + } + _, err := fs.Stat(assets, "index.html") + return err == nil +} + +func candidateAssetPaths() []string { + return []string{ + filepath.Join("webui", "dist"), + } +} diff --git a/internal/webserver/assets/.keep b/internal/webserver/assets/.keep new file mode 100644 index 00000000..6eb6a766 --- /dev/null +++ b/internal/webserver/assets/.keep @@ -0,0 +1,2 @@ +This placeholder is committed so go:embed all:assets always has at least one match in clean checkouts. +Generated WebUI build artifacts in this directory remain ignored. diff --git a/internal/guiapp/assets_embed.go b/internal/webserver/assets_embed.go similarity index 90% rename from internal/guiapp/assets_embed.go rename to internal/webserver/assets_embed.go index 2a80f4a0..75dc4007 100644 --- a/internal/guiapp/assets_embed.go +++ b/internal/webserver/assets_embed.go @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026, Audionut and the autobrr contributors. // SPDX-License-Identifier: GPL-2.0-or-later -package guiapp +package webserver import "embed" diff --git a/internal/webserver/assets_test.go b/internal/webserver/assets_test.go new file mode 100644 index 00000000..d5fa0b74 --- /dev/null +++ b/internal/webserver/assets_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "path/filepath" + "slices" + "testing" +) + +func TestCandidateAssetPaths(t *testing.T) { + t.Parallel() + + paths := candidateAssetPaths() + + if !slices.Contains(paths, filepath.Join("webui", "dist")) { + t.Fatalf("missing repo-root asset path: %v", paths) + } + if len(paths) != 1 { + t.Fatalf("unexpected asset paths: %v", paths) + } +} diff --git a/internal/webserver/auth.go b/internal/webserver/auth.go index a2a1413d..30664411 100644 --- a/internal/webserver/auth.go +++ b/internal/webserver/auth.go @@ -176,6 +176,8 @@ func syncDir(path string) error { return nil } +// sessionManager owns active browser sessions, persists only retained sessions, +// and reports permanent removals after releasing its state lock. type sessionManager struct { ttl time.Duration cleanupEvery time.Duration @@ -184,6 +186,7 @@ type sessionManager struct { stopOnce sync.Once store *sessionStore logf func(string, ...any) + onRemoved func(string) mu sync.Mutex sessions map[string]session } @@ -218,6 +221,34 @@ func (m *sessionManager) SetLogger(logf func(string, ...any)) { m.logf = logf } +// SetRemovedCallback installs a callback invoked after the manager mutex is +// released whenever a session is permanently removed. +func (m *sessionManager) SetRemovedCallback(callback func(string)) { + if m == nil { + return + } + m.mu.Lock() + m.onRemoved = callback + m.mu.Unlock() +} + +func (m *sessionManager) notifyRemoved(ids ...string) { + if m == nil || len(ids) == 0 { + return + } + m.mu.Lock() + callback := m.onRemoved + m.mu.Unlock() + if callback == nil { + return + } + for _, id := range ids { + if strings.TrimSpace(id) != "" { + callback(id) + } + } +} + func (m *sessionManager) Create(username string, retainLogin bool) (session, error) { id, err := randomString(32) if err != nil { @@ -269,6 +300,7 @@ func (m *sessionManager) Get(id string) (session, bool) { m.logPersistError("cleanup expired retained session", err) } } + m.notifyRemoved(id) return session{}, false } m.mu.Unlock() @@ -286,12 +318,14 @@ func (m *sessionManager) GetByCSRFToken(token string) (session, bool) { now := time.Now().UTC() changedRetained := false + removed := make([]string, 0) var matched session matchedOK := false m.mu.Lock() for id, current := range m.sessions { if now.After(current.ExpiresAt) { delete(m.sessions, id) + removed = append(removed, id) if current.Retain { changedRetained = true } @@ -309,6 +343,7 @@ func (m *sessionManager) GetByCSRFToken(token string) (session, bool) { m.logPersistError("cleanup expired retained session", err) } } + m.notifyRemoved(removed...) if matchedOK { return matched, true } @@ -329,6 +364,9 @@ func (m *sessionManager) Delete(id string) error { return err } } + if ok { + m.notifyRemoved(id) + } return nil } @@ -362,10 +400,12 @@ func (m *sessionManager) cleanupLoop() { func (m *sessionManager) deleteExpired(now time.Time) { m.mu.Lock() changed := false + removed := make([]string, 0) for id, current := range m.sessions { if now.After(current.ExpiresAt) { delete(m.sessions, id) + removed = append(removed, id) if current.Retain { changed = true } @@ -378,6 +418,7 @@ func (m *sessionManager) deleteExpired(now time.Time) { m.logPersistError("cleanup expired retained sessions", err) } } + m.notifyRemoved(removed...) } func (m *sessionManager) loadPersisted() error { diff --git a/internal/webserver/backend.go b/internal/webserver/backend.go index c8ea5587..9963a626 100644 --- a/internal/webserver/backend.go +++ b/internal/webserver/backend.go @@ -19,53 +19,82 @@ import ( "github.com/autobrr/upbrr/internal/authmaterial" "github.com/autobrr/upbrr/internal/config" "github.com/autobrr/upbrr/internal/config/importer" - "github.com/autobrr/upbrr/internal/configstore" - "github.com/autobrr/upbrr/internal/cookies" "github.com/autobrr/upbrr/internal/core" internalerrors "github.com/autobrr/upbrr/internal/errors" "github.com/autobrr/upbrr/internal/filesystem" - "github.com/autobrr/upbrr/internal/guiapp" - "github.com/autobrr/upbrr/internal/guishared" - "github.com/autobrr/upbrr/internal/imagehostpolicy" + imagehostpolicy "github.com/autobrr/upbrr/internal/imagehosting/policy" "github.com/autobrr/upbrr/internal/logging" - "github.com/autobrr/upbrr/internal/paths" - "github.com/autobrr/upbrr/internal/pathutil" + pathutil "github.com/autobrr/upbrr/internal/pathing" + paths "github.com/autobrr/upbrr/internal/pathing/layout" "github.com/autobrr/upbrr/internal/services/bdinfo" "github.com/autobrr/upbrr/internal/services/db" - "github.com/autobrr/upbrr/internal/trackerauth" - "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/internal/sourcelayout" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" + sharedjobs "github.com/autobrr/upbrr/internal/webserver/jobs" "github.com/autobrr/upbrr/pkg/api" ) const previewTimeout = 30 * time.Minute +// metadataPreparationRequest is the transport-neutral command shared by prepare +// and reset routes for one browser-selected source. +type metadataPreparationRequest struct { + // CorrelationID binds emitted progress to the initiating browser command. + CorrelationID string + // Path is the selected host filesystem source path. + Path string + // SourceLookupURL optionally supplies tracker or provider lookup evidence. + SourceLookupURL string + // Overrides contains explicit external identity selections. + Overrides api.ExternalIDOverrides + // NameOverrides contains explicit release-name selections. + NameOverrides api.ReleaseNameOverrides + // Playlist carries direct Blu-ray playlist selection state. + Playlist api.PlaylistInstruction + // ConfirmBDMVRescan permits replacement of a partial cached Blu-ray analysis. + ConfirmBDMVRescan bool +} + +// blurayCandidateSelectionRequest selects one candidate within a correlated preparation attempt. +type blurayCandidateSelectionRequest struct { + // CorrelationID binds emitted progress to the initiating browser command. + CorrelationID string + // Path is the selected host filesystem source path. + Path string + // ReleaseID identifies the candidate returned by the current preview. + ReleaseID string +} + +func newTrackerAuthService(cfg config.Config, logger api.Logger) *trackerauth.Service { + return trackerauth.NewServiceWithRegistryAndLogger(cfg, trackerimpl.MustNewRegistry(), logger) +} + // Backend owns the embedded web API runtime and request-scoped background jobs. type Backend struct { - runtimeMu sync.RWMutex - cfg config.Config - core api.Core - coreInitErr error - logger *logging.Logger - repo *db.SQLiteRepository - hub *eventHub + runtimeMu sync.RWMutex + cfg config.Config + runtimeGeneration uint64 + capabilities CoreCapabilities + coreOwner LifecycleOwner + coreInitErr error + logger *logging.Logger + repo *db.SQLiteRepository + hub *eventHub streamMu sync.Mutex streams map[string]*backendLogStream streamWG sync.WaitGroup - dupeMu sync.Mutex - dupes map[string]*dupeCheckJob - dupeWG sync.WaitGroup - - uploadMu sync.Mutex - uploads map[string]*trackerUploadJob - uploadWG sync.WaitGroup - - dvdMenuMu sync.Mutex - dvdMenus map[string]*dvdMenuCaptureJob - dvdMenuWG sync.WaitGroup + // jobEngine owns session-scoped duplicate-check and tracker-upload jobs. + jobEngine *sharedjobs.Engine + jobOwnerMu sync.Mutex + jobOwners map[string]*sharedjobs.OwnerHandle + reviewMu sync.Mutex + reviews *UploadReviewRegistry - sharedCookieMigrator func(context.Context, string, api.Logger) error + activationInitMu sync.Mutex + activator *RuntimeActivator } type backendLogStream struct { @@ -78,11 +107,15 @@ type backendLogStream struct { } type runOptions struct { - Debug bool NoSeed bool RunLogLevel string } +// logExclusions stores muted log patterns for the WebUI. +type logExclusions struct { + Patterns []string `json:"patterns"` +} + // NewBackend constructs a Backend using a background context. func NewBackend(cfg config.Config, hub *eventHub) (*Backend, error) { return NewBackendWithContext(context.Background(), cfg, hub) @@ -111,50 +144,68 @@ func NewBackendWithContext(ctx context.Context, cfg config.Config, hub *eventHub return nil, fmt.Errorf("web: %w", err) } - var coreSvc api.Core + var capabilities CoreCapabilities + var coreOwner LifecycleOwner var coreInitErr error if err := cfg.Validate(); err != nil { coreInitErr = err logger.Warnf("web: config invalid, core disabled until settings are fixed: %v", err) } else { - coreSvc, err = core.NewWithContext(ctx, api.CoreDependencies{ + coreSvc, coreErr := core.NewWithContext(ctx, api.CoreDependencies{ Config: cfg, Logger: logger, Services: api.ServiceSet{ Filesystem: filesystem.NewValidator(), }, - Repository: repo, + Repository: repo.RepositoryCapabilities(), + RepositoryOwner: repo, }) - if err != nil { + if coreErr != nil { _ = repo.Close() _ = logger.Close() - return nil, fmt.Errorf("web: %w", err) + return nil, fmt.Errorf("web: %w", coreErr) + } + capabilities, coreOwner = BindCoreCapabilities(coreSvc) + } + + backend := &Backend{ + cfg: cfg, + runtimeGeneration: AllocateRuntimeGenerationID(), + capabilities: capabilities, + coreOwner: coreOwner, + coreInitErr: coreInitErr, + logger: logger, + repo: repo, + hub: hub, + streams: make(map[string]*backendLogStream), + jobOwners: make(map[string]*sharedjobs.OwnerHandle), + } + if _, err := backend.runtimeActivator(); err != nil { + if coreOwner != nil { + _ = coreOwner.Close() } + _ = repo.Close() + _ = logger.Close() + return nil, fmt.Errorf("web: %w", err) } - - return &Backend{ - cfg: cfg, - core: coreSvc, - coreInitErr: coreInitErr, - logger: logger, - repo: repo, - hub: hub, - streams: make(map[string]*backendLogStream), - dupes: make(map[string]*dupeCheckJob), - uploads: make(map[string]*trackerUploadJob), - dvdMenus: make(map[string]*dvdMenuCaptureJob), - }, nil + backend.jobEngine = sharedjobs.New(webJobSink{hub: hub, logger: logger}, sharedjobs.Config{ + UploadProgress: sharedjobs.UploadProgressPolicy{ + MinInterval: trackerUploadSnapshotThrottle, + HashRateDeltaMiB: trackerUploadHashRateEmitDeltaMiB, + }, + }) + return backend, nil } // Close stops active background work and releases runtime, repository, and log resources. func (b *Backend) Close() error { b.stopAllLogStreams() - b.stopAllDupeJobs() - b.stopAllUploadJobs() - b.stopAllDVDMenuJobs() + if b.jobEngine != nil { + b.jobEngine.Close() + } rt := b.runtimeSnapshot() - if rt.core != nil { - _ = rt.core.Close() + if rt.coreOwner != nil { + _ = rt.coreOwner.Close() } if b.repo != nil { _ = b.repo.Close() @@ -165,119 +216,215 @@ func (b *Backend) Close() error { return nil } -func (b *Backend) requireCore() error { - _, err := b.requireRuntime() - return err -} - -func (b *Backend) requireHistoryRepo() error { - if b == nil || b.repo == nil { - return errors.New("history repository not initialized") - } - return nil -} - +// DetectDiscType classifies the selected host filesystem release path. func (b *Backend) DetectDiscType(ctx context.Context, path string) (string, error) { if strings.TrimSpace(path) == "" { return "", errors.New("path is required") } ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() - return wrapWebResult(filesystem.DetectDiscType(ctx, path)) + layout, err := sourcelayout.Resolve(ctx, path) + if err != nil { + return wrapWebResult("", err) + } + return layout.DiscType, nil } -func (b *Backend) FetchMetadata(sessionID string, path string, sourceLookupURL string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, confirmBDMVRescan bool) (api.MetadataPreview, error) { +// withPreparationProgress installs the sole WebUI progress boundary. It +// requires correlation, sanitizes display text, timestamps updates, and emits +// them only to the initiating session. +func (b *Backend) withPreparationProgress(ctx context.Context, sessionID string, correlationID string) (context.Context, error) { + correlationID = strings.TrimSpace(correlationID) + if correlationID == "" { + return nil, errors.New("preparation correlation ID is required") + } + progressCtx := api.WithPreparationProgressReporter(ctx, func(update api.PreparationProgressUpdate) { + update.CorrelationID = correlationID + update.Label = logging.SanitizeMessage(strings.TrimSpace(update.Label)) + update.Message = logging.SanitizeMessage(strings.TrimSpace(update.Message)) + update.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + b.hub.Emit(sessionID, "preparation:progress", update) + }) + progressCtx = bdinfo.WithProgressReporter(progressCtx, func(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + api.EmitPreparationProgress( + progressCtx, + api.NewPreparationProgressUpdate(api.PreparationPhaseBDInfo, api.PreparationProgressRunning, line), + ) + }) + return progressCtx, nil +} + +// withImageUploadProgress installs the WebUI image-host progress boundary. It +// correlates, sanitizes, timestamps, and scopes advisory updates to one session. +func (b *Backend) withImageUploadProgress(ctx context.Context, sessionID string, correlationID string) (context.Context, error) { + correlationID = strings.TrimSpace(correlationID) + if correlationID == "" { + return nil, errors.New("image upload correlation ID is required") + } + return api.WithImageUploadProgressReporter(ctx, func(update api.ImageUploadProgressUpdate) { + update.CorrelationID = correlationID + update.AttemptID = logging.SanitizeMessage(strings.TrimSpace(update.AttemptID)) + update.Host = logging.SanitizeMessage(strings.TrimSpace(update.Host)) + update.UsageScope = logging.SanitizeMessage(strings.TrimSpace(update.UsageScope)) + for index := range update.Trackers { + update.Trackers[index] = logging.SanitizeMessage(strings.TrimSpace(update.Trackers[index])) + } + update.Message = logging.SanitizeMessage(strings.TrimSpace(update.Message)) + update.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + b.hub.Emit(sessionID, "image-upload:progress", update) + }), nil +} + +// FetchMetadata prepares and caches a metadata preview for the selected release +// path while emitting correlation-scoped progress to sessionID. +func (b *Backend) FetchMetadata( + ctx context.Context, + sessionID string, + command metadataPreparationRequest, +) (api.MetadataPreview, error) { rt, err := b.requireRuntime() if err != nil { return api.MetadataPreview{}, err } - trimmedPath := strings.TrimSpace(path) + metadataCore, err := rt.metadataCore() + if err != nil { + return api.MetadataPreview{}, err + } + trimmedPath := strings.TrimSpace(command.Path) if trimmedPath == "" { return api.MetadataPreview{}, errors.New("path is required") } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() - progressCtx := api.WithMetadataProgressReporter(ctx, func(update api.MetadataProgressUpdate) { - if strings.TrimSpace(update.Path) == "" { - update.Path = trimmedPath - } - if strings.TrimSpace(update.Timestamp) == "" { - update.Timestamp = time.Now().UTC().Format(time.RFC3339) - } - b.hub.Emit(sessionID, "metadata:progress", update) - }) + progressCtx, err := b.withPreparationProgress(ctx, sessionID, command.CorrelationID) + if err != nil { + return api.MetadataPreview{}, err + } req := api.Request{ - Paths: []string{trimmedPath}, - Mode: api.ModeGUI, - Trackers: append([]string{}, trackersList...), - SourceLookupURL: strings.TrimSpace(sourceLookupURL), + SourcePath: trimmedPath, + SourceLookupURL: strings.TrimSpace(command.SourceLookupURL), Options: rt.baseUploadOptions(), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - ConfirmBDMVRescan: confirmBDMVRescan, + ExternalIDOverrides: command.Overrides, + ReleaseNameOverrides: command.NameOverrides, + PlaylistInstruction: command.Playlist, + ConfirmBDMVRescan: command.ConfirmBDMVRescan, } - return wrapWebResult(rt.core.FetchMetadataPreview(progressCtx, req)) + preparationCore, err := rt.releasePreparationCore() + if err != nil { + return api.MetadataPreview{}, err + } + _, ref, err := PrepareGeneration(progressCtx, preparationCore, req, api.PreparationIntentPreview) + if err != nil { + return api.MetadataPreview{}, fmt.Errorf("web: %w", err) + } + preview, err := metadataCore.FetchAcceptedMetadataPreview(progressCtx, ref) + if err != nil { + return api.MetadataPreview{}, fmt.Errorf("web: %w", err) + } + preview.ReleaseNameOverrides = command.NameOverrides + return preview, nil } -type blurayCandidateSelector interface { - SelectBlurayCandidate(ctx context.Context, sourcePath string, releaseID string) (api.MetadataPreview, error) +// PrepareRelease creates or reuses one canonical prepared-release generation. +func (b *Backend) PrepareRelease(input api.PrepareInput) (api.PrepareResult, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.PrepareResult{}, err + } + preparationCore, err := rt.releasePreparationCore() + if err != nil { + return api.PrepareResult{}, err + } + if strings.TrimSpace(input.SourcePath) == "" { + return api.PrepareResult{}, errors.New("path is required") + } + ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + defer cancel() + return wrapWebResult(preparationCore.PrepareRelease(ctx, input)) } -func (b *Backend) SelectBlurayCandidate(path string, releaseID string) (api.MetadataPreview, error) { - if strings.TrimSpace(path) == "" { +// SelectBlurayCandidate updates the prepared release with the chosen Blu-ray +// metadata candidate and emits correlation-scoped progress to sessionID. +func (b *Backend) SelectBlurayCandidate( + ctx context.Context, + sessionID string, + command blurayCandidateSelectionRequest, +) (api.MetadataPreview, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.MetadataPreview{}, err + } + selectionCore, err := rt.selectionCore() + if err != nil { + return api.MetadataPreview{}, err + } + if strings.TrimSpace(command.Path) == "" { return api.MetadataPreview{}, errors.New("path is required") } - if strings.TrimSpace(releaseID) == "" { + if strings.TrimSpace(command.ReleaseID) == "" { return api.MetadataPreview{}, errors.New("release ID is required") } - if err := b.requireCore(); err != nil { + ctx, cancel := context.WithTimeout(ctx, previewTimeout) + defer cancel() + progressCtx, err := b.withPreparationProgress(ctx, sessionID, command.CorrelationID) + if err != nil { return api.MetadataPreview{}, err } - selector, ok := b.currentCore().(blurayCandidateSelector) - if !ok { - return api.MetadataPreview{}, errors.New("blu-ray candidate selection is unavailable in this build") - } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) - defer cancel() - return wrapWebResult(selector.SelectBlurayCandidate(ctx, path, releaseID)) + finish := api.BeginPreparationProgress(progressCtx, api.PreparationPhaseCandidateSelection, "Applying Blu-ray candidate selection.") + preview, err := selectionCore.SelectBlurayCandidate(progressCtx, command.Path, command.ReleaseID) + finish(err) + return wrapWebResult(preview, err) } -func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, confirmBDMVRescan bool) (api.MetadataPreview, error) { - if err := b.requireCore(); err != nil { +// ResetMetadata removes persisted source artifacts, then rebuilds prepared +// metadata using the supplied overrides and correlation-scoped progress. +func (b *Backend) ResetMetadata( + ctx context.Context, + sessionID string, + command metadataPreparationRequest, +) (api.MetadataPreview, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.MetadataPreview{}, err + } + metadataCore, err := rt.metadataCore() + if err != nil { return api.MetadataPreview{}, err } if b.repo == nil { return api.MetadataPreview{}, errors.New("config repository not initialized") } - trimmedPath := strings.TrimSpace(path) + trimmedPath := strings.TrimSpace(command.Path) if trimmedPath == "" { return api.MetadataPreview{}, errors.New("path is required") } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() - progressCtx := api.WithMetadataProgressReporter(ctx, func(update api.MetadataProgressUpdate) { - if strings.TrimSpace(update.Path) == "" { - update.Path = trimmedPath - } - if strings.TrimSpace(update.Timestamp) == "" { - update.Timestamp = time.Now().UTC().Format(time.RFC3339) - } - b.hub.Emit(sessionID, "metadata:progress", update) - }) + progressCtx, err := b.withPreparationProgress(ctx, sessionID, command.CorrelationID) + if err != nil { + return api.MetadataPreview{}, err + } + resetFinish := api.BeginPreparationProgress(progressCtx, api.PreparationPhaseResetCleanup, "Removing previous metadata artifacts.") - tmpRoot, err := db.Subdir(b.currentConfig().MainSettings.DBPath, "tmp") + tmpRoot, err := db.Subdir(rt.cfg.MainSettings.DBPath, "tmp") if err != nil { + resetFinish(err) return api.MetadataPreview{}, fmt.Errorf("reset metadata: resolve tmp dir: %w", err) } artifactPaths := make([]string, 0) shots, err := b.repo.ListScreenshotsByPath(ctx, trimmedPath) if err != nil { + resetFinish(err) return api.MetadataPreview{}, fmt.Errorf("reset metadata: list screenshots: %w", err) } for _, shot := range shots { @@ -285,6 +432,7 @@ func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL s } uploaded, err := b.repo.ListUploadedImagesByPath(ctx, trimmedPath) if err != nil { + resetFinish(err) return api.MetadataPreview{}, fmt.Errorf("reset metadata: list uploaded images: %w", err) } for _, image := range uploaded { @@ -292,6 +440,7 @@ func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL s } finals, err := b.repo.ListFinalSelections(ctx, trimmedPath) if err != nil { + resetFinish(err) return api.MetadataPreview{}, fmt.Errorf("reset metadata: list final selections: %w", err) } for _, image := range finals { @@ -300,21 +449,19 @@ func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL s artifactPaths = slices.Compact(artifactPaths) tmpDirs := make(map[string]struct{}) - fallbackBase := paths.ReleaseTempBase(api.PreparedMetadata{}, trimmedPath) + fallbackBase := paths.ReleaseTempBaseFor(trimmedPath, api.ReleaseInfo{}) tmpDirs[filepath.Join(tmpRoot, fallbackBase)] = struct{}{} stored, err := b.repo.GetByPath(ctx, trimmedPath) if err == nil { - releaseBase := paths.ReleaseTempBase(api.PreparedMetadata{ - Release: api.ReleaseInfo{ - Title: stored.Title, - Alt: stored.Alt, - Year: stored.Year, - Category: string(stored.Category), - Source: stored.Source, - Type: stored.Type, - Group: stored.Group, - }, - }, trimmedPath) + releaseBase := paths.ReleaseTempBaseFor(trimmedPath, api.ReleaseInfo{ + Title: stored.Title, + Alt: stored.Alt, + Year: stored.Year, + Category: string(stored.Category), + Source: stored.Source, + Type: stored.Type, + Group: stored.Group, + }) tmpDirs[filepath.Join(tmpRoot, releaseBase)] = struct{}{} } for _, filePath := range artifactPaths { @@ -324,6 +471,7 @@ func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL s } } if err := b.repo.PurgeContentData(ctx, trimmedPath); err != nil { + resetFinish(err) return api.MetadataPreview{}, fmt.Errorf("reset metadata: purge sqlite: %w", err) } for _, filePath := range artifactPaths { @@ -332,52 +480,58 @@ func (b *Backend) ResetMetadata(sessionID string, path string, sourceLookupURL s for dir := range tmpDirs { _ = removeIfWithinRoot(tmpRoot, dir, true) } + resetFinish(nil) req := api.Request{ - Paths: []string{trimmedPath}, - Mode: api.ModeGUI, - Trackers: append([]string{}, trackersList...), - SourceLookupURL: strings.TrimSpace(sourceLookupURL), - Options: b.baseUploadOptions(), + SourcePath: trimmedPath, + SourceLookupURL: strings.TrimSpace(command.SourceLookupURL), + Options: rt.baseUploadOptions(), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - ConfirmBDMVRescan: confirmBDMVRescan, + ExternalIDOverrides: command.Overrides, + ReleaseNameOverrides: command.NameOverrides, + PlaylistInstruction: command.Playlist, + ConfirmBDMVRescan: command.ConfirmBDMVRescan, } - return wrapWebResult(b.currentCore().FetchMetadataPreview(progressCtx, req)) -} - -func (b *Backend) CheckDupes(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string) (api.DupeCheckSummary, error) { - if err := b.requireCore(); err != nil { - return api.DupeCheckSummary{}, err + preparationCore, err := rt.releasePreparationCore() + if err != nil { + return api.MetadataPreview{}, err } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) - defer cancel() - req := api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, - Trackers: append([]string{}, trackersList...), - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + _, ref, err := PrepareGeneration(progressCtx, preparationCore, req, api.PreparationIntentPreview) + if err != nil { + return api.MetadataPreview{}, fmt.Errorf("web: %w", err) + } + preview, err := metadataCore.FetchAcceptedMetadataPreview(progressCtx, ref) + if err != nil { + return api.MetadataPreview{}, fmt.Errorf("web: %w", err) } - return wrapWebResult(b.currentCore().CheckDupes(ctx, req)) + preview.ReleaseNameOverrides = command.NameOverrides + return preview, nil } -func (b *Backend) FetchPreparation(sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, ignoreDupesFor []string) (api.PreparationPreview, error) { - if err := b.requireCore(); err != nil { +// FetchPreparation returns tracker review data prepared from the current release snapshot. +func (b *Backend) FetchPreparation( + sessionID string, + path string, + overrides api.ExternalIDOverrides, + nameOverrides api.ReleaseNameOverrides, + trackersList []string, + ignoreDupesFor []string, +) (api.PreparationPreview, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.PreparationPreview{}, err + } + preparationCore, err := rt.preparationCore() + if err != nil { return api.PreparationPreview{}, err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() req := api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, - Trackers: append([]string{}, trackersList...), - IgnoreDupesFor: normalizeTrackerList(ignoreDupesFor), - Options: b.baseUploadOptions(), - + SourcePath: strings.TrimSpace(path), + Trackers: append([]string{}, trackersList...), + IgnoreDupesFor: normalizeTrackerList(ignoreDupesFor), + Options: rt.baseUploadOptions(), ExternalIDOverrides: overrides, ReleaseNameOverrides: nameOverrides, } @@ -390,274 +544,427 @@ func (b *Backend) FetchPreparation(sessionID string, path string, overrides api. "line": line, }) }) - return wrapWebResult(b.currentCore().FetchPreparationPreview(progressCtx, req)) + ref, err := prepareWebReleaseRef(progressCtx, rt, req, api.PreparationIntentDescription) + if err != nil { + return api.PreparationPreview{}, err + } + return wrapWebResult(preparationCore.FetchAcceptedPreparationPreview(progressCtx, api.DescriptionInput{ + Release: ref, + Trackers: append([]string(nil), trackersList...), + Options: req.Options, + })) } -func (b *Backend) FetchTrackerDryRun(sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, ignoreDupesFor []string, questionnaireAnswers map[string]map[string]string, descriptionGroups []api.DescriptionBuilderGroup, debug bool, noSeed bool, runLogLevel string) (api.TrackerDryRunPreview, error) { +// FetchTrackerDryRun runs prepared payload previews for selected trackers. +func (b *Backend) FetchTrackerDryRun( + ctx context.Context, + sessionID string, + dupeJobID string, + release api.ReleaseRef, + trackersList []string, + ignoreDupesFor []string, + questionnaireAnswers map[string]map[string]string, + descriptionGroups []api.DescriptionBuilderGroup, + noSeed bool, + runLogLevel string, +) (api.TrackerDryRunPreview, error) { rt, err := b.requireRuntime() if err != nil { return api.TrackerDryRunPreview{}, err } - runOpts, err := b.buildRunOptions(debug, noSeed, runLogLevel) + if !rt.capabilities.PreparedDryRunReady() { + return api.TrackerDryRunPreview{}, ErrPreparedDryRunUnavailable + } + runOpts, err := b.buildRunOptions(noSeed, runLogLevel) if err != nil { return api.TrackerDryRunPreview{}, err } - b.logDebugf("web: tracker dry-run request path=%s debug=%t no_seed=%t run_log_level=%s", strings.TrimSpace(path), debug, noSeed, runOpts.RunLogLevel) - runCore, runLogger, err := b.buildRunCoreFromSnapshot(rt, runOpts) + release, err = normalizeExactRelease(release, api.OperationKindDryRun) if err != nil { return api.TrackerDryRunPreview{}, err } - defer func() { - _ = runCore.Close() - _ = runLogger.Close() - }() - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + b.logDebugf("web: tracker dry-run request path=%s no_seed=%t run_log_level=%s", release.SourcePath, noSeed, runOpts.RunLogLevel) + resolvedTrackers := normalizeTrackerList(trackersList) + duplicateEvidence, err := b.acceptedDryRunDuplicateEvidence(sessionID, dupeJobID, release, rt.generationID, resolvedTrackers) + if err != nil { + return api.TrackerDryRunPreview{}, err + } + ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() req := api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, DescriptionGroups: api.CloneDescriptionBuilderGroups(descriptionGroups), - Trackers: append([]string{}, trackersList...), + Trackers: append([]string{}, resolvedTrackers...), IgnoreDupesFor: normalizeTrackerList(ignoreDupesFor), - IgnoreTrackerRuleFailures: false, Options: buildRunUploadOptions(rt.cfg, runOpts), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, TrackerQuestionnaireAnswers: cloneQuestionnaireAnswers(questionnaireAnswers), } - req.Options.DryRun = true - if err := guishared.SeedRunCorePreparedMeta(ctx, rt.core, runCore, req); err != nil { - return api.TrackerDryRunPreview{}, fmt.Errorf("web: %w", err) - } progressCtx := api.WithUploadProgressReporter(ctx, func(update api.UploadProgressUpdate) { b.hub.Emit(sessionID, trackerUploadProgressEvent, update) }) + if rt.logger != nil { + operationLogger, scopeErr := logging.NewOperationLogger(rt.logger, runOpts.RunLogLevel) + if scopeErr != nil { + return api.TrackerDryRunPreview{}, fmt.Errorf("web: scoped dry-run logger: %w", scopeErr) + } + progressCtx = logging.WithOperationLogger(progressCtx, operationLogger) + } progressCtx = bdinfo.WithProgressReporter(progressCtx, func(line string) { if strings.TrimSpace(line) == "" { return } b.hub.Emit(sessionID, "bdinfo:progress", map[string]string{ - "path": strings.TrimSpace(path), + "path": release.SourcePath, "line": line, }) }) - return wrapWebResult(runCore.FetchTrackerDryRunPreview(progressCtx, req)) + return wrapWebResult(rt.capabilities.DryRun.RunAcceptedTrackerDryRun(progressCtx, api.TrackerDryRunPlan{ + Input: api.TrackerDryRunInput{ + Release: release, + Trackers: append([]string(nil), req.Trackers...), + IgnoreDupesFor: append([]string(nil), req.IgnoreDupesFor...), + QuestionnaireAnswers: cloneQuestionnaireAnswers(req.TrackerQuestionnaireAnswers), + DescriptionGroups: api.CloneDescriptionBuilderGroups(req.DescriptionGroups), + TrackerConfigOverrides: req.TrackerConfigOverrides, + TrackerSiteOverrides: req.TrackerSiteOverrides, + ImageHostOverrides: req.ImageHostOverrides, + TorrentOverrides: req.TorrentOverrides, + Options: req.Options, + }, + Duplicate: duplicateEvidence, + })) } -func (b *Backend) FetchDescriptionBuilder(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, ignoreDupesFor []string) (api.DescriptionBuilderPreview, error) { - if err := b.requireCore(); err != nil { +// acceptedDryRunDuplicateEvidence returns duplicate evidence only when the +// retained job belongs to the session and matches the exact release, runtime +// generation, and ordered tracker selection used by the dry run. +func (b *Backend) acceptedDryRunDuplicateEvidence( + sessionID string, + jobID string, + release api.ReleaseRef, + runtimeGeneration uint64, + trackers []string, +) (api.AcceptedDuplicateEvidence, error) { + missing := func(message string, cause error) (api.AcceptedDuplicateEvidence, error) { + return api.AcceptedDuplicateEvidence{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureMissingPrerequisite, + Operation: api.OperationKindDryRun, + Message: message, + Recovery: api.OperationRecoveryCompletePrerequisite, + }, cause) + } + if strings.TrimSpace(jobID) == "" || b == nil || b.jobEngine == nil { + return missing("Run duplicate checking before starting a dry run.", errors.New("duplicate job is required")) + } + snapshot, err := b.jobEngine.DupeSnapshot(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)) + if err != nil { + return missing("Duplicate-check results are unavailable. Run duplicate checking again.", err) + } + status := strings.ToLower(strings.TrimSpace(snapshot.Status)) + if status != sharedjobs.StatusCompleted && status != sharedjobs.StatusCompletedWithErrors { + return missing("Duplicate checking must finish before starting a dry run.", fmt.Errorf("duplicate job status is %s", status)) + } + if snapshot.Release != release { + return api.AcceptedDuplicateEvidence{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureStaleGeneration, + Operation: api.OperationKindDryRun, + Message: "Duplicate-check results are stale. Run duplicate checking again.", + Recovery: api.OperationRecoveryCompletePrerequisite, + }, errors.New("duplicate job release does not match dry-run release")) + } + if snapshot.RuntimeGeneration != runtimeGeneration { + return api.AcceptedDuplicateEvidence{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureStaleGeneration, + Operation: api.OperationKindDryRun, + Message: "Runtime settings changed. Run duplicate checking again.", + Recovery: api.OperationRecoveryCompletePrerequisite, + }, errors.New("duplicate job runtime generation is stale")) + } + requested := normalizeTrackerList(snapshot.RequestedTrackers) + if !slices.Equal(requested, trackers) { + return missing( + "Duplicate-check results do not match the selected trackers. Run duplicate checking again.", + errors.New("duplicate job tracker selection does not match dry-run selection"), + ) + } + return api.NewAcceptedDuplicateEvidence(release, requested, snapshot.Summary), nil +} + +// FetchDescriptionBuilder returns editable tracker description groups for the prepared release. +func (b *Backend) FetchDescriptionBuilder( + release api.ReleaseRef, + trackersList []string, +) (api.DescriptionBuilderPreview, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.DescriptionBuilderPreview{}, err + } + descriptionCore, err := rt.descriptionCore() + if err != nil { return api.DescriptionBuilderPreview{}, err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, - Trackers: append([]string{}, trackersList...), - IgnoreDupesFor: normalizeTrackerList(ignoreDupesFor), - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindDescription) + if err != nil { + return api.DescriptionBuilderPreview{}, err } - return wrapWebResult(b.currentCore().FetchDescriptionBuilderPreview(ctx, req)) + return wrapWebResult(descriptionCore.FetchAcceptedDescriptionBuilderPreview(ctx, api.DescriptionInput{ + Release: ref, + Trackers: append([]string(nil), trackersList...), + Options: rt.baseUploadOptions(), + })) } +// RenderDescription converts tracker markup into sanitized preview HTML. func (b *Backend) RenderDescription(raw string) (string, error) { - if err := b.requireCore(); err != nil { + rt, err := b.requireRuntime() + if err != nil { + return "", err + } + descriptionCore, err := rt.descriptionCore() + if err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebResult(b.currentCore().RenderDescription(ctx, raw)) + return wrapWebResult(descriptionCore.RenderDescription(ctx, raw)) } -func (b *Backend) SaveDescriptionOverride(path string, groupKey string, raw string, trackers []string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) (api.DescriptionBuilderGroup, error) { - if err := b.requireCore(); err != nil { +// SaveDescriptionOverride publishes edited description groups to the prepared release. +func (b *Backend) SaveDescriptionOverride( + release api.ReleaseRef, + groupKey string, + raw string, + trackers []string, +) (api.DescriptionBuilderGroup, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.DescriptionBuilderGroup{}, err + } + descriptionCore, err := rt.descriptionCore() + if err != nil { return api.DescriptionBuilderGroup{}, err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebResult(b.currentCore().SaveDescriptionOverride(ctx, api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, - DescriptionOverrideGroup: strings.TrimSpace(groupKey), - Trackers: append([]string{}, trackers...), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindDescription) + if err != nil { + return api.DescriptionBuilderGroup{}, err + } + return wrapWebResult(descriptionCore.SaveAcceptedDescriptionOverride(ctx, api.DescriptionInput{ + Release: ref, + Trackers: append([]string(nil), trackers...), + GroupKey: strings.TrimSpace(groupKey), + Options: rt.baseUploadOptions(), }, raw)) } -func (b *Backend) DiscoverPlaylists(path string) ([]api.PlaylistInfo, error) { - if err := b.requireCore(); err != nil { +// DiscoverPlaylists returns Blu-ray playlists available under the selected release path. +func (b *Backend) DiscoverPlaylists(ctx context.Context, path string) ([]api.PlaylistInfo, error) { + rt, err := b.requireRuntime() + if err != nil { return nil, err } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) - defer cancel() - return wrapWebResult(b.currentCore().DiscoverPlaylists(ctx, path)) -} - -func (b *Backend) SavePlaylistSelection(path string, playlists []string, useAll bool) error { - if err := b.requireCore(); err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) - defer cancel() - return wrapWebError(b.currentCore().SavePlaylistSelection(ctx, path, playlists, useAll)) -} - -func (b *Backend) LoadPlaylistSelection(path string) (api.PlaylistSelection, error) { - if err := b.requireCore(); err != nil { - return api.PlaylistSelection{}, err + playlistCore, err := rt.playlistCore() + if err != nil { + return nil, err } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() - return wrapWebResult(b.currentCore().LoadPlaylistSelection(ctx, path)) + return wrapWebResult(playlistCore.DiscoverPlaylists(ctx, path)) } +// BrowseDirectory lists a host filesystem directory for the requested frontend browse mode. func (b *Backend) BrowseDirectory(path string, mode string) (api.BrowseDirectoryResponse, error) { if b == nil { return api.BrowseDirectoryResponse{}, errors.New("backend not initialized") } - fallback := guishared.BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) - return wrapWebResult(guishared.BrowseDirectory(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback)) + fallback := BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) + return wrapWebResult(BrowseDirectory(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback)) } +// BrowseDirectoryWithinRoot lists a directory only when it remains within the authorized host root. func (b *Backend) BrowseDirectoryWithinRoot(path string, mode string, root string) (api.BrowseDirectoryResponse, error) { if b == nil { return api.BrowseDirectoryResponse{}, errors.New("backend not initialized") } - fallback := guishared.BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) - return wrapWebResult(guishared.BrowseDirectoryWithinRoot(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback, root)) + fallback := BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) + return wrapWebResult(BrowseDirectoryWithinRoot(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback, root)) } +// BrowseDirectoryWithinRoots lists a directory only when it remains within an authorized host root. func (b *Backend) BrowseDirectoryWithinRoots(path string, mode string, roots []string) (api.BrowseDirectoryResponse, error) { if b == nil { return api.BrowseDirectoryResponse{}, errors.New("backend not initialized") } - fallback := guishared.BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) - return wrapWebResult(guishared.BrowseDirectoryWithinRoots(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback, roots)) + fallback := BrowseDirectoryFallback(b.currentConfig().MainSettings.DBPath) + return wrapWebResult(BrowseDirectoryWithinRoots(api.BrowseDirectoryRequest{Path: path, Mode: mode}, fallback, roots)) } -func (b *Backend) FetchScreenshotPlan(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) (api.ScreenshotPlan, error) { - if err := b.requireCore(); err != nil { +// FetchScreenshotPlan returns existing images and capture selections for a prepared release. +func (b *Backend) FetchScreenshotPlan(release api.ReleaseRef) (api.ScreenshotPlan, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.ScreenshotPlan{}, err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return api.ScreenshotPlan{}, err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return api.ScreenshotPlan{}, err } - return wrapWebResult(b.currentCore().FetchScreenshotPlan(ctx, req)) + return wrapWebResult(screenshotCore.FetchAcceptedScreenshotPlan(ctx, api.MediaPlanInput{ + Release: ref, + Count: rt.baseUploadOptions().Screens, + })) } -func (b *Backend) GenerateScreenshots(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, selections []api.ScreenshotSelection, purpose api.ScreenshotPurpose) (api.ScreenshotResult, error) { - if err := b.requireCore(); err != nil { +// GenerateScreenshots captures selected frames and persists the resulting managed images. +func (b *Backend) GenerateScreenshots( + release api.ReleaseRef, + selections []api.ScreenshotSelection, + purpose api.ScreenshotPurpose, +) (api.ScreenshotResult, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.ScreenshotResult{}, err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return api.ScreenshotResult{}, err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return api.ScreenshotResult{}, err } - return wrapWebResult(b.currentCore().GenerateScreenshots(ctx, req, selections, purpose)) + return wrapWebResult(screenshotCore.GenerateAcceptedScreenshots(ctx, api.MediaPlanInput{ + Release: ref, + Count: rt.baseUploadOptions().Screens, + Purpose: purpose, + }, selections)) } -func (b *Backend) PreviewScreenshotFrame(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, timestampSeconds float64) (string, error) { - if err := b.requireCore(); err != nil { +// PreviewScreenshotFrame returns an encoded preview for the requested timestamp in seconds. +func (b *Backend) PreviewScreenshotFrame( + release api.ReleaseRef, + timestampSeconds float64, +) (string, error) { + rt, err := b.requireRuntime() + if err != nil { + return "", err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return "", err } - preview, err := b.currentCore().PreviewScreenshotFrame(ctx, req, timestampSeconds) + preview, err := screenshotCore.PreviewAcceptedScreenshotFrame(ctx, api.MediaPlanInput{ + Release: ref, + Count: rt.baseUploadOptions().Screens, + }, timestampSeconds) if err != nil { return "", fmt.Errorf("web: %w", err) } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(preview.ImageBytes), nil } -func (b *Backend) DeleteScreenshot(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, imagePath string) error { - if err := b.requireCore(); err != nil { +// DeleteScreenshot removes one managed screenshot and its prepared-release reference. +func (b *Backend) DeleteScreenshot(release api.ReleaseRef, imagePath string) error { + rt, err := b.requireRuntime() + if err != nil { + return err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().DeleteScreenshot(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return err + } + return wrapWebError(screenshotCore.DeleteAcceptedScreenshot(ctx, api.MediaPlanInput{ + Release: ref, + Count: rt.baseUploadOptions().Screens, }, imagePath)) } -func (b *Backend) DeleteTrackerImageURL(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, imageURL string) error { - if err := b.requireCore(); err != nil { +// DeleteTrackerImageURL removes one tracker image URL from prepared release metadata. +func (b *Backend) DeleteTrackerImageURL(release api.ReleaseRef, imageURL string) error { + rt, err := b.requireRuntime() + if err != nil { + return err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().DeleteTrackerImageURL(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - }, imageURL)) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return err + } + return wrapWebError(screenshotCore.DeleteAcceptedTrackerImageURL(ctx, api.ImageHostingInput{Release: ref}, imageURL)) } -func (b *Backend) SaveFinalScreenshotSelections(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, images []api.ScreenshotImage) error { - if err := b.requireCore(); err != nil { +// SaveFinalScreenshotSelections persists the ordered image set selected for tracker preparation. +func (b *Backend) SaveFinalScreenshotSelections( + release api.ReleaseRef, + images []api.ScreenshotImage, +) error { + rt, err := b.requireRuntime() + if err != nil { + return err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().SaveFinalScreenshotSelections(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return err + } + return wrapWebError(screenshotCore.SaveAcceptedFinalScreenshotSelections(ctx, api.MediaPlanInput{ + Release: ref, + Count: rt.baseUploadOptions().Screens, }, images)) } -func (b *Backend) ImportMenuImages(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, paths []string) error { - if err := b.requireCore(); err != nil { +// ImportMenuImages copies selected host images into managed DVD menu storage. +func (b *Backend) ImportMenuImages(release api.ReleaseRef, paths []string) error { + rt, err := b.requireRuntime() + if err != nil { + return err + } + screenshotCore, err := rt.screenshotCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().ImportMenuImages(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: b.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - }, paths)) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return err + } + return wrapWebError(screenshotCore.ImportAcceptedMenuImages(ctx, api.MediaPlanInput{Release: ref}, paths)) } +// ReadScreenshotImage returns a managed screenshot encoded for frontend display. func (b *Backend) ReadScreenshotImage(path string) (string, error) { trimmed := strings.TrimSpace(path) if trimmed == "" { @@ -673,68 +980,130 @@ func (b *Backend) ReadScreenshotImage(path string) (string, error) { return "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload), nil } -func (b *Backend) ListUploadCandidates(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) ([]api.ScreenshotImage, error) { +// ListUploadCandidates returns managed screenshots eligible for image-host upload. +func (b *Backend) ListUploadCandidates(release api.ReleaseRef) ([]api.ScreenshotImage, error) { rt, err := b.requireRuntime() if err != nil { return nil, err } + hostedImageCore, err := rt.hostedImageCore() + if err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebResult(rt.core.ListUploadCandidates(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: rt.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - })) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return nil, err + } + return wrapWebResult(hostedImageCore.ListAcceptedUploadCandidates(ctx, api.ImageHostingInput{Release: ref})) } -func (b *Backend) ListUploadedImages(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) ([]api.UploadedImageLink, error) { +// ListUploadedImages returns persisted image-host links for the prepared release. +func (b *Backend) ListUploadedImages(release api.ReleaseRef) ([]api.UploadedImageLink, error) { rt, err := b.requireRuntime() if err != nil { return nil, err } + hostedImageCore, err := rt.hostedImageCore() + if err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebResult(rt.core.ListUploadedImages(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: rt.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - })) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return nil, err + } + return wrapWebResult(hostedImageCore.ListAcceptedUploadedImages(ctx, api.ImageHostingInput{Release: ref})) } -func (b *Backend) UploadImages(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackersList []string, host string, images []api.ScreenshotImage) (api.UploadImagesResult, error) { +// UploadImages sends selected managed images to the requested configured host and persists returned links. +func (b *Backend) UploadImages( + ctx context.Context, + sessionID string, + correlationID string, + release api.ReleaseRef, + trackersList []string, + host string, + images []api.ScreenshotImage, +) (api.UploadImagesResult, error) { rt, err := b.requireRuntime() if err != nil { return api.UploadImagesResult{}, err } - ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) + hostedImageCore, err := rt.hostedImageCore() + if err != nil { + return api.UploadImagesResult{}, err + } + ctx, cancel := context.WithTimeout(ctx, previewTimeout) defer cancel() - return wrapWebResult(rt.core.UploadImages(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - Options: rt.baseUploadOptions(), - - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - Trackers: append([]string{}, trackersList...), - }, host, images)) + progressCtx, err := b.withImageUploadProgress(ctx, sessionID, correlationID) + if err != nil { + return api.UploadImagesResult{}, err + } + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return api.UploadImagesResult{}, err + } + return wrapWebResult(hostedImageCore.UploadAcceptedImages(progressCtx, api.ImageHostingInput{ + Release: ref, + Trackers: append([]string(nil), trackersList...), + Host: host, + }, images)) } -func (b *Backend) DeleteUploadedImage(path string, imagePath string, host string) error { - if err := b.requireCore(); err != nil { +// DeleteUploadedImage removes one persisted image-host link from release state. +func (b *Backend) DeleteUploadedImage(release api.ReleaseRef, imagePath string, host string) error { + rt, err := b.requireRuntime() + if err != nil { + return err + } + hostedImageCore, err := rt.hostedImageCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().DeleteUploadedImage(ctx, api.Request{ - Paths: []string{path}, - Mode: api.ModeGUI, - }, imagePath, host)) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return err + } + return wrapWebError(hostedImageCore.DeleteAcceptedUploadedImage(ctx, api.ImageHostingInput{Release: ref}, imagePath, host)) +} + +// normalizeExactRelease trims and validates the source path while preserving +// the caller's exact prepared generation. +func normalizeExactRelease(release api.ReleaseRef, operation api.OperationKind) (api.ReleaseRef, error) { + release.SourcePath = strings.TrimSpace(release.SourcePath) + if release.SourcePath == "" || release.Generation == 0 { + return api.ReleaseRef{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureInvalidSource, + Operation: operation, + Message: "An exact prepared release is required.", + Recovery: api.OperationRecoveryRefreshRelease, + }, errors.New("exact release reference is required")) + } + return release, nil +} + +// prepareWebReleaseRef creates or reuses canonical preparation and returns only +// the exact reference needed by the requested browser operation. +func prepareWebReleaseRef( + ctx context.Context, + rt backendRuntimeSnapshot, + request api.Request, + intent api.PreparationIntent, +) (api.ReleaseRef, error) { + preparer, err := rt.releasePreparationCore() + if err != nil { + return api.ReleaseRef{}, err + } + _, ref, err := PrepareGeneration(ctx, preparer, request, intent) + if err != nil { + return api.ReleaseRef{}, fmt.Errorf("web: %w", err) + } + return ref, nil } // GetConfig returns the current exportable config as JSON with encrypted @@ -751,8 +1120,7 @@ func (b *Backend) GetConfig() (string, error) { // DVD menu capability probe for embedded-web diagnostics. func (b *Backend) GetApplicationInfo() (api.ApplicationInfo, error) { rt := b.runtimeSnapshot() - provider, _ := rt.core.(api.DVDMenuCapabilityProvider) - return guishared.CurrentApplicationInfo(context.Background(), provider), nil + return CurrentApplicationInfo(context.Background(), rt.capabilities.DiagnosticProbe), nil } // ExportConfig returns the exportable config, using plaintext secrets only @@ -775,6 +1143,7 @@ func (b *Backend) ExportConfig() (string, error) { return wrapWebResult(config.ExportToJSON(cfg)) } +// GetDefaultConfig returns the built-in configuration serialized for the settings editor. func (b *Backend) GetDefaultConfig() (string, error) { cfg, err := config.LoadEmbeddedDefaultConfig() if err != nil { @@ -789,7 +1158,7 @@ func (b *Backend) ListTrackerAuthCapabilities() ([]api.TrackerAuthCapability, er if b == nil { return nil, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Capabilities(context.Background())) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Capabilities(context.Background())) } // GetTrackerAuthStatus reports local auth state for tracker from the current @@ -798,7 +1167,7 @@ func (b *Backend) GetTrackerAuthStatus(tracker string) (api.TrackerAuthStatus, e if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Status(context.Background(), tracker)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Status(context.Background(), tracker)) } // ImportTrackerAuthCookieContent imports browser-supplied cookie content with @@ -807,7 +1176,7 @@ func (b *Backend) ImportTrackerAuthCookieContent(ctx context.Context, tracker st if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).ImportCookies(ctx, tracker, fileName, content)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).ImportCookies(ctx, tracker, fileName, content)) } // TestTrackerAuth validates tracker auth with ctx so canceled web requests stop @@ -816,7 +1185,7 @@ func (b *Backend) TestTrackerAuth(ctx context.Context, tracker string) (api.Trac if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Validate(ctx, tracker)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Validate(ctx, tracker)) } // LoginTrackerAuth attempts credential-based tracker auth with ctx and returns @@ -825,7 +1194,7 @@ func (b *Backend) LoginTrackerAuth(ctx context.Context, tracker string, req api. if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Login(ctx, tracker, req)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Login(ctx, tracker, req)) } // SubmitTrackerAuth2FA completes an active manual 2FA challenge with ctx and @@ -834,7 +1203,7 @@ func (b *Backend) SubmitTrackerAuth2FA(ctx context.Context, challengeID string, if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Submit2FA(ctx, challengeID, code)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Submit2FA(ctx, challengeID, code)) } // DeleteTrackerAuth removes stored tracker cookies and tracker-specific auth @@ -843,7 +1212,7 @@ func (b *Backend) DeleteTrackerAuth(ctx context.Context, tracker string) (api.Tr if b == nil { return api.TrackerAuthStatus{}, errors.New("backend not initialized") } - return wrapWebResult(trackerauth.NewServiceWithLogger(b.currentConfig(), b.currentLogger()).Delete(ctx, tracker)) + return wrapWebResult(newTrackerAuthService(b.currentConfig(), b.currentLogger()).Delete(ctx, tracker)) } // exportableConfig returns the normalized config snapshot and the DB path that @@ -925,11 +1294,8 @@ func (b *Backend) allowUnencryptedExport(dbPath string) (bool, error) { return false, fmt.Errorf("web: %w", err) } -// SaveConfig validates encrypted browser settings, builds the replacement -// runtime, migrates shared cookies, then persists the non-env config before -// installing the runtime. Runtime build, migration, or save failures leave the -// persisted config and active runtime unchanged; env overrides apply only to -// the installed runtime config. +// SaveConfig decodes encrypted browser settings and delegates the complete +// config/runtime transition to the shared runtime activator. func (b *Backend) SaveConfig(payload string) error { if b.repo == nil { return errors.New("config repository not initialized") @@ -938,27 +1304,14 @@ func (b *Backend) SaveConfig(payload string) error { if err != nil { return fmt.Errorf("web: %w", err) } - if _, err := config.MergeMissingTrackerDefaults(cfg); err != nil { - return fmt.Errorf("web: %w", err) - } - currentCfg := b.currentConfig() - if strings.TrimSpace(cfg.MainSettings.DBPath) == "" { - cfg.MainSettings.DBPath = currentCfg.MainSettings.DBPath - } - if !pathutil.SamePath(cfg.MainSettings.DBPath, currentCfg.MainSettings.DBPath) { - return errors.New("changing main_settings.db_path requires restart") - } - cfg.MainSettings.DBPath = currentCfg.MainSettings.DBPath - if err := cfg.Validate(); err != nil { + activator, err := b.runtimeActivator() + if err != nil { return fmt.Errorf("web: %w", err) } - runtimeCfg := *cfg - config.ApplyEnvOverrides(&runtimeCfg) - runtimeCfg.MainSettings.DBPath = currentCfg.MainSettings.DBPath - if err := runtimeCfg.Validate(); err != nil { + if err := activator.Activate(context.Background(), *cfg); err != nil { return fmt.Errorf("web: %w", err) } - return b.saveAndApplyConfig(context.Background(), cfg, runtimeCfg, cfg.MainSettings.DBPath) + return nil } const configImportMaxBytes = importer.MaxFileBytes @@ -984,21 +1337,12 @@ func (b *Backend) ImportConfig(fileName, fileContent string) (string, []string, return "", nil, fmt.Errorf("web: %w", err) } - currentCfg := b.currentConfig() - cfg.MainSettings.DBPath = currentCfg.MainSettings.DBPath - - if err := cfg.Validate(); err != nil { - return "", nil, fmt.Errorf("validate imported config: %w", err) - } - runtimeCfg := *cfg - config.ApplyEnvOverrides(&runtimeCfg) - runtimeCfg.MainSettings.DBPath = currentCfg.MainSettings.DBPath - if err := runtimeCfg.Validate(); err != nil { - return "", nil, fmt.Errorf("validate imported config: %w", err) + activator, err := b.runtimeActivator() + if err != nil { + return "", nil, fmt.Errorf("web: %w", err) } - - if err := b.saveAndApplyConfig(context.Background(), cfg, runtimeCfg, cfg.MainSettings.DBPath); err != nil { - return "", nil, err + if err := activator.Activate(context.Background(), *cfg); err != nil { + return "", nil, fmt.Errorf("web: %w", err) } result := "imported config" @@ -1008,183 +1352,158 @@ func (b *Backend) ImportConfig(fileName, fileContent string) (string, []string, return result, warnings, nil } -// saveAndApplyConfig builds the replacement runtime before any repository -// writes, then migrates shared cookies, persists cfg, and installs the runtime -// as one ordered transition. If migration or persistence fails, the built -// runtime is closed and the active runtime is left untouched. -func (b *Backend) saveAndApplyConfig(ctx context.Context, cfg *config.Config, runtimeCfg config.Config, dbPath string) error { - if err := validateCookieAuthMaterial(dbPath); err != nil { - if !errors.Is(err, cookies.ErrAuthHelperUnavailable) { - return fmt.Errorf("web: validate cookie auth before config save: %w", err) - } - } - - rt, err := b.buildConfigRuntime(ctx, runtimeCfg) +// ListTrackerCatalog returns ordered tracker identity, config schemas, and local +// configured state without exposing current credential values. +func (b *Backend) ListTrackerCatalog() (api.TrackerCatalog, error) { + registry, err := trackerimpl.NewRegistry() if err != nil { - return err - } - if err := b.ensureSharedCookieMigrationForRuntime(ctx, dbPath, rt.Logger); err != nil { - closeBuiltRuntime(rt) - return fmt.Errorf("web: cookie migration failed: %w", err) + return api.TrackerCatalog{}, fmt.Errorf("webserver: tracker registry: %w", err) } - if err := b.saveConfigToRepository(ctx, cfg, dbPath); err != nil { - closeBuiltRuntime(rt) - return err - } - b.installConfigRuntime(runtimeCfg, rt) - return nil -} - -// saveConfigToRepository persists browser config changes through the already-open -// repository. -func (b *Backend) saveConfigToRepository(ctx context.Context, cfg *config.Config, dbPath string) error { - if err := configstore.SaveToRepository(ctx, cfg, b.repo, dbPath); err != nil { - return fmt.Errorf("web: %w", err) + schemas, err := config.OrderedTrackerSchemas() + if err != nil { + return api.TrackerCatalog{}, fmt.Errorf("webserver: tracker config catalog: %w", err) } - return nil -} -// validateCookieAuthMaterial returns ErrAuthHelperUnavailable only when auth -// material is absent; malformed material remains an error so callers can abort -// before cookie encryption metadata is initialized. -func validateCookieAuthMaterial(dbPath string) error { - material, err := authmaterial.LoadFromDBPath(dbPath) - if err != nil { - if errors.Is(err, authmaterial.ErrUnavailable) { - return cookies.ErrAuthHelperUnavailable + cfg := b.currentConfig() + entries := make([]api.TrackerCatalogEntry, 0, len(schemas)) + seen := make(map[string]struct{}, len(schemas)) + for _, schema := range schemas { + descriptor, ok := registry.LookupDescriptor(schema.Name) + if !ok { + return api.TrackerCatalog{}, fmt.Errorf("webserver: tracker config catalog entry %s has no implementation", schema.Name) } - return fmt.Errorf("load auth helper: %w", err) - } - if _, _, err := material.PrimaryHelper(); err != nil { - if errors.Is(err, authmaterial.ErrUnavailable) { - return cookies.ErrAuthHelperUnavailable + fields := make([]api.TrackerCatalogField, len(schema.Fields)) + for index, field := range schema.Fields { + fields[index] = api.TrackerCatalogField{ + Key: field.JSONKey, + YAMLKey: field.YAMLKey, + Default: field.Default, + Activation: field.Activation, + } } - return fmt.Errorf("derive auth helper: %w", err) - } - return nil -} - -// buildConfigRuntime wraps shared runtime construction with web-specific error -// context. -func (b *Backend) buildConfigRuntime(ctx context.Context, cfg config.Config) (guishared.Runtime, error) { - rt, err := guishared.BuildRuntime(ctx, cfg, b.repo) - if err != nil { - return guishared.Runtime{}, fmt.Errorf("web: %w", err) - } - return rt, nil -} - -// ensureSharedCookieMigration syncs cookie encryption metadata and migrates -// legacy cookie files against the shared repository before SkipCookieMigration -// runtimes serve uploads. Missing auth material is logged and treated as -// retryable on a later settings save. -func (b *Backend) ensureSharedCookieMigration(ctx context.Context, dbPath string, logger api.Logger) error { - if b.repo == nil { - return errors.New("repository not initialized") - } - if logger == nil { - logger = api.NopLogger{} + trackerCfg, _ := trackerConfigByName(cfg.Trackers.Trackers, schema.Name) + entries = append(entries, api.TrackerCatalogEntry{ + Name: schema.Name, + Family: string(descriptor.Family), + BaseURL: descriptor.BaseURL, + UploadContentMode: string(descriptor.UploadContentMode), + Fields: fields, + Configured: config.TrackerConfigured(trackerCfg, schema), + }) + seen[schema.Name] = struct{}{} } - if err := cookies.SyncCookieEncryptionWithAuth(ctx, b.repo.RawDB(), dbPath); err != nil { - if errors.Is(err, cookies.ErrAuthHelperUnavailable) { - logger.Debugf("web: cookie encryption sync skipped: web auth helper unavailable") - } else { - return fmt.Errorf("cookies encryption sync: %w", err) + for _, name := range registry.Names() { + if _, ok := seen[name]; !ok { + return api.TrackerCatalog{}, fmt.Errorf("webserver: tracker implementation %s has no config catalog entry", name) } } - cookiesDir, err := db.CookiePath(dbPath, "") - if err != nil { - logger.Debugf("web: failed to resolve cookies directory: %v", err) - return nil - } - if err := cookies.EnsureCookieMigration(ctx, b.repo.RawDB(), dbPath, cookiesDir, logger); err != nil { - if errors.Is(err, cookies.ErrAuthHelperUnavailable) { - logger.Debugf("web: cookie migration skipped: web auth helper unavailable") - return nil + unsupported := make([]string, 0) + for name := range cfg.Trackers.Trackers { + normalized := strings.ToUpper(strings.TrimSpace(name)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; !ok { + unsupported = append(unsupported, name) } - return fmt.Errorf("cookies migration: %w", err) - } - return nil -} - -// ensureSharedCookieMigrationForRuntime runs the configured migration hook and -// returns hard migration errors before the replacement runtime is installed. -func (b *Backend) ensureSharedCookieMigrationForRuntime(ctx context.Context, dbPath string, logger api.Logger) error { - if b.sharedCookieMigrator != nil { - return b.sharedCookieMigrator(ctx, dbPath, logger) } - return b.ensureSharedCookieMigration(ctx, dbPath, logger) + slices.SortFunc(unsupported, func(left, right string) int { + return strings.Compare(strings.ToUpper(left), strings.ToUpper(right)) + }) + return api.TrackerCatalog{Entries: entries, Unsupported: unsupported}, nil } -// installConfigRuntime swaps in a newly built runtime, rebinds event/log -// streams, and closes the previous core and logger after replacement. -func (b *Backend) installConfigRuntime(cfg config.Config, rt guishared.Runtime) { - oldCore, oldLogger := b.replaceRuntime(cfg, rt.Core, rt.Logger) - if b.hub != nil { - b.hub.SetLogger(rt.Logger) - } - b.rebindLogStreams(oldLogger, rt.Logger) - if oldCore != nil { - _ = oldCore.Close() +func trackerConfigByName(entries map[string]config.TrackerConfig, name string) (config.TrackerConfig, bool) { + if cfg, ok := entries[name]; ok { + return cfg, true } - if oldLogger != nil { - _ = oldLogger.Close() + for entryName, cfg := range entries { + if strings.EqualFold(strings.TrimSpace(entryName), strings.TrimSpace(name)) { + return cfg, true + } } + return config.TrackerConfig{}, false } -// closeBuiltRuntime closes a runtime that was built but not installed. -func closeBuiltRuntime(rt guishared.Runtime) { - if rt.Core != nil { - _ = rt.Core.Close() +// GetImageHostPolicyMetadata returns image-host policy metadata consumed by settings and upload UI. +func (b *Backend) GetImageHostPolicyMetadata() (imagehostpolicy.Metadata, error) { + registry, err := trackerimpl.NewRegistry() + if err != nil { + return imagehostpolicy.Metadata{}, fmt.Errorf("webserver: tracker registry: %w", err) } - if rt.Logger != nil { - _ = rt.Logger.Close() + metadata := imagehostpolicy.Metadata{ + UploadHosts: imagehostpolicy.KnownUploadHosts(), + TrackerUploadHosts: make(map[string][]string), + OwnedHosts: make(map[string]string), } + for _, tracker := range registry.Names() { + policy, ok := registry.LookupImageHostPolicy(tracker) + if !ok { + continue + } + hosts := append([]string(nil), policy.AllowedHosts...) + if host := strings.ToLower(strings.TrimSpace(policy.ConditionalHost)); host != "" { + hosts = append(hosts, host) + } + uploadHosts := make([]string, 0, len(hosts)) + for _, host := range hosts { + normalized := strings.ToLower(strings.TrimSpace(host)) + if imagehostpolicy.IsUploadHost(normalized) && !slices.Contains(uploadHosts, normalized) { + uploadHosts = append(uploadHosts, normalized) + } + } + if len(uploadHosts) > 0 { + metadata.TrackerUploadHosts[tracker] = uploadHosts + } + for _, host := range policy.OwnedHosts { + metadata.OwnedHosts[strings.ToLower(strings.TrimSpace(host))] = tracker + } + } + return metadata, nil } -func (b *Backend) ListKnownTrackers() ([]string, error) { - return trackers.KnownTrackers(), nil -} - -func (b *Backend) GetImageHostPolicyMetadata() (imagehostpolicy.Metadata, error) { - return imagehostpolicy.PolicyMetadata(), nil -} - +// ListHistory returns persisted release history in repository-defined order. func (b *Backend) ListHistory() ([]api.HistoryEntry, error) { - if err := b.requireHistoryRepo(); err != nil { - return nil, err - } - entries, err := b.repo.ListHistoryEntries(context.Background()) + rt := b.runtimeSnapshot() + history, err := rt.historyCore() if err != nil { - return nil, fmt.Errorf("web: %w", err) - } - result := make([]api.HistoryEntry, 0, len(entries)) - for _, entry := range entries { - entryCopy := entry - entryCopy.LatestUploadStatus = api.HistoryStatusLabel(entry.LatestUploadStatus, entry.RuleFailureCount) - result = append(result, entryCopy) + return nil, err } - return result, nil + return wrapWebResult(history.ListHistory(context.Background())) } +// GetHistoryOverview returns persisted upload and asset detail for one source path. func (b *Backend) GetHistoryOverview(sourcePath string) (api.HistoryOverview, error) { - return historyOverviewFromRepo(b.repo, sourcePath) + rt := b.runtimeSnapshot() + history, err := rt.historyCore() + if err != nil { + return api.HistoryOverview{}, err + } + return wrapWebResult(history.GetHistoryOverview(context.Background(), sourcePath)) } +// DeleteHistoryRelease purges persisted history and managed artifacts for one source path. func (b *Backend) DeleteHistoryRelease(sourcePath string) error { - if err := b.requireCore(); err != nil { + rt, err := b.requireRuntime() + if err != nil { + return err + } + historyCore, err := rt.historyCore() + if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - return wrapWebError(b.currentCore().DeleteHistoryRelease(ctx, strings.TrimSpace(sourcePath))) + return wrapWebError(historyCore.DeleteHistoryRelease(ctx, strings.TrimSpace(sourcePath))) } +// GetLogPath returns the host filesystem path of the active application log. func (b *Backend) GetLogPath() (string, error) { return wrapWebResult(logging.LogPath(b.currentConfig().MainSettings.DBPath)) } +// GetRecentLogs returns up to limit sanitized recent log entries. func (b *Backend) GetRecentLogs(limit int) ([]logging.Entry, error) { logger := b.currentLogger() if logger == nil { @@ -1193,11 +1512,12 @@ func (b *Backend) GetRecentLogs(limit int) ([]logging.Entry, error) { return logger.Recent(limit), nil } +// GetLogExclusions returns persisted frontend log-filter patterns. func (b *Backend) GetLogExclusions() ([]string, error) { if b.repo == nil { return nil, errors.New("config repository not initialized") } - var exclusions guiapp.LogExclusions + var exclusions logExclusions err := config.LoadSectionFromDatabase(context.Background(), "log_exclusions", &exclusions, b.repo) if err != nil { if errorsIsNotFound(err) { @@ -1208,11 +1528,12 @@ func (b *Backend) GetLogExclusions() ([]string, error) { return normalizePatterns(exclusions.Patterns), nil } +// UpdateLogExclusions validates and persists frontend log-filter patterns. func (b *Backend) UpdateLogExclusions(patterns []string) error { if b.repo == nil { return errors.New("config repository not initialized") } - return wrapWebError(config.SaveSectionToDatabase(context.Background(), "log_exclusions", guiapp.LogExclusions{ + return wrapWebError(config.SaveSectionToDatabase(context.Background(), "log_exclusions", logExclusions{ Patterns: normalizePatterns(patterns), }, b.repo)) } @@ -1373,46 +1694,62 @@ func (b *Backend) StopSessionLogStreams(sessionID string) { } } -func (b *Backend) buildRunOptions(debug bool, noSeed bool, runLogLevel string) (runOptions, error) { +func (b *Backend) buildRunOptions(noSeed bool, runLogLevel string) (runOptions, error) { if strings.TrimSpace(runLogLevel) == "" { - return runOptions{Debug: debug, NoSeed: noSeed}, nil + return runOptions{NoSeed: noSeed}, nil } normalized, err := api.ParseLogLevel(runLogLevel) if err != nil { return runOptions{}, fmt.Errorf("web: %w", err) } - return runOptions{Debug: debug, NoSeed: noSeed, RunLogLevel: normalized}, nil + return runOptions{ + NoSeed: noSeed, + RunLogLevel: normalized, + }, nil } // buildRunCoreFromSnapshot creates a per-run core and logger from the same // runtime snapshot used to build upload options. The transient core skips // startup-only legacy cookie migration while sharing the backend repository. -func (b *Backend) buildRunCoreFromSnapshot(rt backendRuntimeSnapshot, opts runOptions) (api.Core, *logging.Logger, error) { - effectiveLogLevel := logging.ResolveEffectiveLevel(rt.cfg.Logging.Level, opts.RunLogLevel, opts.Debug) +// The capability bundle borrows the core; callers must close the returned +// owner and logger separately unless they transfer both to a job. +func (b *Backend) buildRunCoreFromSnapshot( + ctx context.Context, + rt backendRuntimeSnapshot, + opts runOptions, +) (CoreCapabilities, LifecycleOwner, *logging.Logger, error) { + if err := ctx.Err(); err != nil { + return CoreCapabilities{}, nil, nil, fmt.Errorf("web: build run core canceled: %w", err) + } + effectiveLogLevel := logging.ResolveEffectiveLevel(rt.cfg.Logging.Level, opts.RunLogLevel, false) logger, err := logging.NewWithLevel(rt.cfg.Logging, rt.cfg.MainSettings.DBPath, effectiveLogLevel) if err != nil { - return nil, nil, fmt.Errorf("web: %w", err) + return CoreCapabilities{}, nil, nil, fmt.Errorf("web: %w", err) + } + if err := ctx.Err(); err != nil { + _ = logger.Close() + return CoreCapabilities{}, nil, nil, fmt.Errorf("web: build run core canceled: %w", err) } - coreSvc, err := core.New(api.CoreDependencies{ + coreSvc, err := core.NewWithContext(ctx, api.CoreDependencies{ Config: rt.cfg, Logger: logger, Services: api.ServiceSet{ Filesystem: filesystem.NewValidator(), }, - Repository: b.repo, + Repository: b.repo.RepositoryCapabilities(), + RepositoryOwner: b.repo, SkipCookieMigration: true, }) if err != nil { _ = logger.Close() - return nil, nil, fmt.Errorf("web: %w", err) + return CoreCapabilities{}, nil, nil, fmt.Errorf("web: %w", err) } - return coreSvc, logger, nil + capabilities, owner := BindCoreCapabilities(coreSvc) + return capabilities, owner, logger, nil } func buildRunUploadOptions(cfg config.Config, opts runOptions) api.UploadOptions { options := buildBaseMetadataOptions(cfg) - options.Debug = opts.Debug - options.DryRun = opts.Debug options.NoSeed = opts.NoSeed options.RunLogLevel = opts.RunLogLevel return options @@ -1427,15 +1764,6 @@ func buildBaseMetadataOptions(cfg config.Config) api.UploadOptions { } } -func (b *Backend) applyConfig(cfg config.Config) error { - rt, err := b.buildConfigRuntime(context.Background(), cfg) - if err != nil { - return err - } - b.installConfigRuntime(cfg, rt) - return nil -} - func (b *Backend) isPathWithinManagedDirs(candidate string) bool { tmpDir, err := db.Subdir(b.currentConfig().MainSettings.DBPath, "tmp") if err == nil && pathutil.IsWithinRoot(tmpDir, candidate) { @@ -1512,96 +1840,3 @@ func removeIfWithinRoot(root string, target string, recursive bool) error { func errorsIsNotFound(err error) bool { return err != nil && strings.Contains(strings.ToLower(err.Error()), "not found") } - -func preferredHistoryDescriptionOverride(overrides []api.DescriptionOverride) api.DescriptionOverride { - if len(overrides) == 0 { - return api.DescriptionOverride{} - } - for _, override := range overrides { - if strings.TrimSpace(override.GroupKey) == "" { - return override - } - } - for _, override := range overrides { - if strings.TrimSpace(override.Description) != "" { - return override - } - } - return overrides[0] -} - -func historyOverviewFromRepo(repo *db.SQLiteRepository, sourcePath string) (api.HistoryOverview, error) { - if repo == nil { - return api.HistoryOverview{}, errors.New("history repository not initialized") - } - trimmed := strings.TrimSpace(sourcePath) - if trimmed == "" { - return api.HistoryOverview{}, internalerrors.ErrInvalidInput - } - ctx := context.Background() - metadata, err := repo.GetByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview := api.HistoryOverview{ - SourcePath: metadata.Path, - ReleaseTitle: metadata.Title, - ReleaseSource: metadata.Source, - ReleaseResolution: metadata.Resolution, - MetadataUpdatedAt: metadata.UpdatedAt, - Metadata: metadata, - } - if externalIDs, err := repo.GetExternalIDs(ctx, trimmed); err == nil { - overview.ExternalIDs = externalIDs - } - if externalMetadata, err := repo.GetExternalMetadata(ctx, trimmed); err == nil { - overview.ExternalMetadata = externalMetadata - } - if releaseOverrides, err := repo.GetReleaseNameOverrides(ctx, trimmed); err == nil { - overview.ReleaseNameOverrides = releaseOverrides - } - if descriptionOverrides, err := repo.ListDescriptionOverridesByPath(ctx, trimmed); err == nil { - overview.DescriptionOverrides = append([]api.DescriptionOverride(nil), descriptionOverrides...) - overview.DescriptionOverride = preferredHistoryDescriptionOverride(descriptionOverrides) - } - if playlistSelection, err := repo.GetPlaylistSelection(ctx, trimmed); err == nil { - overview.PlaylistSelection = playlistSelection - } - trackerMetadata, err := repo.ListTrackerMetadataByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.TrackerMetadata = trackerMetadata - ruleFailures, err := repo.ListTrackerRuleFailuresByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.TrackerRuleFailures = ruleFailures - screenshots, err := repo.ListScreenshotsByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.Screenshots = screenshots - finalSelections, err := repo.ListFinalSelections(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.FinalSelections = finalSelections - uploadedImages, err := repo.ListUploadedImagesByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.UploadedImages = uploadedImages - uploadHistory, err := repo.ListUploadHistoryByPath(ctx, trimmed) - if err != nil { - return api.HistoryOverview{}, fmt.Errorf("web: %w", err) - } - overview.UploadHistory = uploadHistory - if len(uploadHistory) > 0 { - overview.LatestUploadStatus = uploadHistory[0].Status - overview.LatestUploadAt = uploadHistory[0].CreatedAt - } - blockingRuleFailures := api.CountBlockingRuleFailures(ruleFailures) - overview.StatusLabel = api.HistoryStatusLabel(overview.LatestUploadStatus, blockingRuleFailures) - return overview, nil -} diff --git a/internal/webserver/backend_dry_run_test.go b/internal/webserver/backend_dry_run_test.go new file mode 100644 index 00000000..87f1dd7c --- /dev/null +++ b/internal/webserver/backend_dry_run_test.go @@ -0,0 +1,361 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/autobrr/upbrr/internal/config" + sharedjobs "github.com/autobrr/upbrr/internal/webserver/jobs" + "github.com/autobrr/upbrr/pkg/api" +) + +type dryRunCaptureCapability struct { + plan api.TrackerDryRunPlan +} + +func (c *dryRunCaptureCapability) RunAcceptedTrackerDryRun(ctx context.Context, plan api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) { + if err := ctx.Err(); err != nil { + return api.TrackerDryRunPreview{}, fmt.Errorf("captured dry run canceled: %w", err) + } + c.plan = plan + return api.TrackerDryRunPreview{SourcePath: plan.Input.Release.SourcePath}, nil +} + +type completedDryRunDupeRunner struct{} + +func (completedDryRunDupeRunner) CheckDupes(_ context.Context, input api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + results := make([]api.DupeCheckResult, 0, len(input.Trackers)) + for _, tracker := range input.Trackers { + results = append(results, api.DupeCheckResult{ +Tracker: tracker, + Status: "completed", + Notes: []string{"retained"}, +}) + } + return api.DupeCheckSummary{SourcePath: input.Release.SourcePath, Results: results}, nil +} + +type failedDryRunDupeRunner struct{} + +func (failedDryRunDupeRunner) CheckDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, errors.New("duplicate service unavailable") +} + +type blockingDryRunDupeRunner struct { + started chan struct{} + release chan struct{} +} + +func (r blockingDryRunDupeRunner) CheckDupes(ctx context.Context, _ api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + close(r.started) + select { + case <-ctx.Done(): + return api.DupeCheckSummary{}, fmt.Errorf("blocking duplicate check canceled: %w", ctx.Err()) + case <-r.release: + return api.DupeCheckSummary{}, nil + } +} + +func TestFetchTrackerDryRunUsesActiveCapabilityAndRetainedDupeEvidence(t *testing.T) { + t.Parallel() + + capture := &dryRunCaptureCapability{} + backend := &Backend{ + cfg: config.Config{Logging: config.LoggingConfig{Level: "info"}}, + capabilities: CoreCapabilities{DryRun: capture}, + runtimeGeneration: 41, + hub: newEventHub(), + jobEngine: sharedjobs.New(nil, sharedjobs.Config{}), + jobOwners: make(map[string]*sharedjobs.OwnerHandle), + } + t.Cleanup(backend.jobEngine.Close) + + release := api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 7} + jobID := startCompletedDryRunDupeJob(t, backend, "session-a", release, 41, []string{"AITHER"}) + preview, err := backend.FetchTrackerDryRun( + context.Background(), + "session-a", + jobID, + release, + []string{"AITHER"}, + nil, + nil, + nil, + true, + "trace", + ) + if err != nil { + t.Fatalf("fetch tracker dry run: %v", err) + } + if preview.SourcePath != release.SourcePath || capture.plan.Input.Release != release { + t.Fatalf("preview=%#v plan=%#v", preview, capture.plan) + } + if capture.plan.Input.Options.RunLogLevel != "trace" || !capture.plan.Input.Options.NoSeed { + t.Fatalf("run options = %#v", capture.plan.Input.Options) + } + if capture.plan.Duplicate.Release != release || len(capture.plan.Duplicate.Results) != 1 || capture.plan.Duplicate.Results[0].Tracker != "AITHER" { + t.Fatalf("duplicate evidence = %#v", capture.plan.Duplicate) + } + if backend.capabilities.PreparedGenerationTransfer != nil { + t.Fatal("test unexpectedly installed generation transfer") + } +} + +func TestFetchTrackerDryRunRejectsInvalidDupeJobLineage(t *testing.T) { + t.Parallel() + + release := api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 7} + tests := []struct { + name string + owner string + requestOwner string + requestJob string + jobRelease api.ReleaseRef + jobRuntime uint64 + jobTrackers []string + requestTrackers []string + wantFailure api.OperationFailureCode + }{ + { + name: "missing job", + owner: "session-a", + requestOwner: "session-a", + requestJob: "missing-job", + jobRelease: release, + jobRuntime: 41, + jobTrackers: []string{"AITHER"}, + requestTrackers: []string{"AITHER"}, + wantFailure: api.OperationFailureMissingPrerequisite, + }, + { + name: "foreign owner", + owner: "session-a", + requestOwner: "session-b", + jobRelease: release, + jobRuntime: 41, + jobTrackers: []string{"AITHER"}, + requestTrackers: []string{"AITHER"}, + wantFailure: api.OperationFailureMissingPrerequisite, + }, + { + name: "stale release", + owner: "session-a", + requestOwner: "session-a", + jobRelease: api.ReleaseRef{SourcePath: release.SourcePath, Generation: 6}, + jobRuntime: 41, + jobTrackers: []string{"AITHER"}, + requestTrackers: []string{"AITHER"}, + wantFailure: api.OperationFailureStaleGeneration, + }, + { + name: "stale runtime", + owner: "session-a", + requestOwner: "session-a", + jobRelease: release, + jobRuntime: 40, + jobTrackers: []string{"AITHER"}, + requestTrackers: []string{"AITHER"}, + wantFailure: api.OperationFailureStaleGeneration, + }, + { + name: "tracker mismatch", + owner: "session-a", + requestOwner: "session-a", + jobRelease: release, + jobRuntime: 41, + jobTrackers: []string{"AITHER"}, + requestTrackers: []string{"AITHER", "BLU"}, + wantFailure: api.OperationFailureMissingPrerequisite, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + capture := &dryRunCaptureCapability{} + backend := &Backend{ + capabilities: CoreCapabilities{DryRun: capture}, + runtimeGeneration: 41, + hub: newEventHub(), + jobEngine: sharedjobs.New(nil, sharedjobs.Config{}), + jobOwners: make(map[string]*sharedjobs.OwnerHandle), + } + t.Cleanup(backend.jobEngine.Close) + jobID := startCompletedDryRunDupeJob(t, backend, test.owner, test.jobRelease, test.jobRuntime, test.jobTrackers) + if test.requestJob != "" { + jobID = test.requestJob + } + _, err := backend.FetchTrackerDryRun( + context.Background(), test.requestOwner, jobID, release, test.requestTrackers, nil, nil, nil, true, "", + ) + failure, ok := api.AsOperationFailure(err) + if !ok || failure.Code != test.wantFailure { + t.Fatalf("failure = %#v err=%v", failure, err) + } + if capture.plan.Input.Release.Generation != 0 { + t.Fatalf("dry-run capability was invoked: %#v", capture.plan) + } + }) + } +} + +func TestFetchTrackerDryRunRejectsRunningAndFailedDupeJobs(t *testing.T) { + t.Parallel() + + release := api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 7} + tests := []struct { + name string + runner sharedjobs.DupeRunner + wait string + }{ + { +name: "running", + runner: blockingDryRunDupeRunner{started: make(chan struct{}), release: make(chan struct{})}, + wait: sharedjobs.StatusRunning, +}, + { +name: "failed", + runner: failedDryRunDupeRunner{}, + wait: sharedjobs.StatusFailed, +}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + backend := newDryRunTestBackend(&dryRunCaptureCapability{}) + t.Cleanup(backend.jobEngine.Close) + jobID := startDryRunDupeJob(t, backend, "session-a", release, 41, []string{"AITHER"}, test.runner) + waitForDryRunDupeStatus(t, backend, "session-a", jobID, test.wait) + _, err := backend.FetchTrackerDryRun( + context.Background(), "session-a", jobID, release, []string{"AITHER"}, nil, nil, nil, true, "", + ) + failure, ok := api.AsOperationFailure(err) + if !ok || failure.Code != api.OperationFailureMissingPrerequisite { + t.Fatalf("failure = %#v err=%v", failure, err) + } + if runner, ok := test.runner.(blockingDryRunDupeRunner); ok { + close(runner.release) + } + }) + } +} + +func TestFetchTrackerDryRunDetachesAcceptedEvidenceFromJobSnapshot(t *testing.T) { + t.Parallel() + + capture := &dryRunCaptureCapability{} + backend := newDryRunTestBackend(capture) + t.Cleanup(backend.jobEngine.Close) + release := api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 7} + jobID := startCompletedDryRunDupeJob(t, backend, "session-a", release, 41, []string{"AITHER"}) + if _, err := backend.FetchTrackerDryRun( + context.Background(), "session-a", jobID, release, []string{"AITHER"}, nil, nil, nil, true, "", + ); err != nil { + t.Fatalf("fetch tracker dry run: %v", err) + } + capture.plan.Duplicate.Results[0].Notes[0] = "mutated" + snapshot, err := backend.jobEngine.DupeSnapshot(backend.lookupJobOwner("session-a"), jobID) + if err != nil { + t.Fatalf("reload duplicate snapshot: %v", err) + } + if got := snapshot.Summary.Results[0].Notes[0]; got != "retained" { + t.Fatalf("retained duplicate evidence mutated: %q", got) + } +} + +func newDryRunTestBackend(capability DryRunCapability) *Backend { + return &Backend{ + capabilities: CoreCapabilities{DryRun: capability}, + runtimeGeneration: 41, + hub: newEventHub(), + jobEngine: sharedjobs.New(nil, sharedjobs.Config{}), + jobOwners: make(map[string]*sharedjobs.OwnerHandle), + } +} + +func startCompletedDryRunDupeJob( + t *testing.T, + backend *Backend, + ownerID string, + release api.ReleaseRef, + runtimeGeneration uint64, + trackers []string, +) string { + t.Helper() + jobID := startDryRunDupeJob(t, backend, ownerID, release, runtimeGeneration, trackers, completedDryRunDupeRunner{}) + waitForDryRunDupeStatus(t, backend, ownerID, jobID, sharedjobs.StatusCompleted) + return jobID +} + +func startDryRunDupeJob( + t *testing.T, + backend *Backend, + ownerID string, + release api.ReleaseRef, + runtimeGeneration uint64, + trackers []string, + runner sharedjobs.DupeRunner, +) string { + t.Helper() + owner, err := backend.ensureJobOwner(ownerID) + if err != nil { + t.Fatalf("ensure job owner: %v", err) + } + jobID, err := backend.jobEngine.StartDupe(context.Background(), owner, sharedjobs.DupeSpec{ + CorrelationID: "dry-run-prerequisite", + Snapshot: sharedjobs.DuplicateExecutionSnapshot{ + PreparedGeneration: release.Generation, + RuntimeGeneration: runtimeGeneration, + Input: api.DuplicateCheckInput{ + Release: release, + Trackers: append([]string(nil), trackers...), + }, + }, + Runner: runner, + }) + if err != nil { + t.Fatalf("start dupe job: %v", err) + } + return jobID +} + +func waitForDryRunDupeStatus(t *testing.T, backend *Backend, ownerID string, jobID string, wantStatus string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + snapshot, snapshotErr := backend.jobEngine.DupeSnapshot(backend.lookupJobOwner(ownerID), jobID) + if snapshotErr != nil { + t.Fatalf("load dupe snapshot: %v", snapshotErr) + } + if snapshot.Status == wantStatus { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for duplicate job status %q", wantStatus) +} + +func TestFetchTrackerDryRunPropagatesCanceledContext(t *testing.T) { + t.Parallel() + + backend := &Backend{ + capabilities: CoreCapabilities{DryRun: &dryRunCaptureCapability{}}, + runtimeGeneration: 41, + hub: newEventHub(), + jobEngine: sharedjobs.New(nil, sharedjobs.Config{}), + jobOwners: make(map[string]*sharedjobs.OwnerHandle), + } + t.Cleanup(backend.jobEngine.Close) + release := api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 7} + jobID := startCompletedDryRunDupeJob(t, backend, "session-a", release, 41, []string{"AITHER"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := backend.FetchTrackerDryRun(ctx, "session-a", jobID, release, []string{"AITHER"}, nil, nil, nil, true, "") + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled error = %v", err) + } +} diff --git a/internal/webserver/backend_repo_test.go b/internal/webserver/backend_repo_test.go index dbfc5afc..ca96f7fa 100644 --- a/internal/webserver/backend_repo_test.go +++ b/internal/webserver/backend_repo_test.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -24,60 +25,6 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) -func TestBackendApplyConfigKeepsSharedRepositoryUsable(t *testing.T) { - t.Parallel() - - repoPath := filepath.Join(t.TempDir(), "backend.db") - repo, err := db.OpenWithLogger(repoPath, nil) - if err != nil { - t.Fatalf("open repo: %v", err) - } - t.Cleanup(func() { - _ = repo.Close() - }) - if err := repo.Migrate(); err != nil { - t.Fatalf("migrate repo: %v", err) - } - - cfg := config.Config{ - MainSettings: config.MainSettingsConfig{TMDBAPI: "x", DBPath: repoPath}, - ScreenshotHandling: config.ScreenshotHandlingConfig{Screens: 1}, - Logging: config.LoggingConfig{Level: "error"}, - } - - backend := &Backend{ - cfg: cfg, - repo: repo, - hub: newEventHub(), - } - t.Cleanup(func() { - if backend.core != nil { - _ = backend.core.Close() - } - if backend.logger != nil { - _ = backend.logger.Close() - } - }) - - if err := backend.applyConfig(cfg); err != nil { - t.Fatalf("apply config: %v", err) - } - if backend.core == nil { - t.Fatal("expected core to be initialized") - } - if err := backend.core.Close(); err != nil { - t.Fatalf("close core: %v", err) - } - - if err := repo.Save(context.Background(), db.FileMetadata{ - Path: filepath.Join(t.TempDir(), "after-apply.mkv"), - Title: "After Apply", - UpdatedAt: time.Now().UTC().Truncate(time.Second), - }); err != nil { - t.Fatalf("expected shared repo to remain usable after core close: %v", err) - } -} - func TestNewBackendKeepsSharedRepositoryUsableAfterCoreClose(t *testing.T) { t.Parallel() @@ -96,10 +43,10 @@ func TestNewBackendKeepsSharedRepositoryUsableAfterCoreClose(t *testing.T) { _ = backend.Close() }) - if backend.core == nil { + if backend.coreOwner == nil { t.Fatal("expected startup core to be initialized") } - if err := backend.core.Close(); err != nil { + if err := backend.coreOwner.Close(); err != nil { t.Fatalf("close core: %v", err) } if backend.repo == nil { @@ -288,7 +235,7 @@ func TestBackendGetConfigFallbackUsesSingleRuntimeSnapshot(t *testing.T) { assertExport := func(want config.Config) { t.Helper() - backend.replaceRuntime(want, nil, nil) + backend.replaceRuntime(want, CoreCapabilities{}, nil) payload, err := backend.GetConfig() if err != nil { t.Fatalf("get config: %v", err) @@ -382,31 +329,280 @@ func TestBackendGetConfigDatabaseConfigUsesSingleRuntimeSnapshotForDBPath(t *tes t.Fatalf("DBPath fallback: got %q want %q", exported.MainSettings.DBPath, wantDBPath) } } - backend.replaceRuntime(cfgA, nil, nil) + backend.replaceRuntime(cfgA, CoreCapabilities{}, nil) assertExport(pathA) - backend.replaceRuntime(cfgB, nil, nil) + backend.replaceRuntime(cfgB, CoreCapabilities{}, nil) assertExport(pathB) } -func TestBackendFetchMetadataPropagatesSkipAutoTorrentSetting(t *testing.T) { +func TestBackendFetchMetadataMapsSearchPolicyAndRecoveryControlSeparately(t *testing.T) { t.Parallel() coreSvc := &preparedMetaTestCore{} + playlist := api.PlaylistInstruction{Set: true, Selected: []string{"00001.mpls"}} backend := &Backend{ cfg: config.Config{ Metadata: config.MetadataConfig{SkipAutoTorrent: true}, ScreenshotHandling: config.ScreenshotHandlingConfig{Screens: 3}, }, - core: coreSvc, - hub: newEventHub(), + capabilities: webTestCapabilities(coreSvc), + coreOwner: coreSvc, + hub: newEventHub(), } - _, err := backend.FetchMetadata("session", "C:\\releases\\Example.mkv", "", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, false) + _, err := backend.FetchMetadata(context.Background(), "session", metadataPreparationRequest{ + CorrelationID: "attempt-1", + Path: "C:\\releases\\Example.mkv", + Playlist: playlist, + ConfirmBDMVRescan: true, + }) if err != nil { t.Fatalf("fetch metadata: %v", err) } - if !coreSvc.fetchReq.Options.SkipAutoTorrent { - t.Fatalf("expected skip_auto_torrent request option, got %#v", coreSvc.fetchReq.Options) + if coreSvc.prepareInput.SourcePath != `C:\releases\Example.mkv` { + t.Fatalf("expected canonical prepare input, got %#v", coreSvc.prepareInput) + } + if !coreSvc.prepareInput.Instructions.Playlist.Set || !slices.Equal(coreSvc.prepareInput.Instructions.Playlist.Selected, []string{"00001.mpls"}) { + t.Fatalf("expected direct playlist instruction, got %#v", coreSvc.prepareInput.Instructions.Playlist) + } + if !coreSvc.prepareInput.Search.Skip { + t.Fatalf("expected client-search skip policy, got %#v", coreSvc.prepareInput.Search) + } + if !coreSvc.prepareInput.Controls.ConfirmBDMVRescan { + t.Fatalf("expected explicit BDMV recovery control, got %#v", coreSvc.prepareInput.Controls) + } + if coreSvc.fetchReq.SourcePath != "" { + t.Fatalf("legacy metadata request was invoked: %#v", coreSvc.fetchReq) + } +} + +func TestBackendRequestCapabilitiesRejectPartialRuntimeBundles(t *testing.T) { + t.Parallel() + + stub := &backendSnapshotGuardCore{} + metadataOnly := CoreCapabilities{Metadata: stub} + selectionOnly := CoreCapabilities{Selection: stub} + path := "C:\\releases\\Example.mkv" + tests := []struct { + name string + capabilities CoreCapabilities + want string + call func(*Backend) error + }{ + { + name: "metadata", + capabilities: selectionOnly, + want: "metadata capability unavailable", + call: func(backend *Backend) error { + _, err := backend.FetchMetadata(context.Background(), "session", metadataPreparationRequest{CorrelationID: "attempt-1", Path: path}) + return err + }, + }, + { + name: "selection", + capabilities: metadataOnly, + want: "blu-ray selection capability unavailable", + call: func(backend *Backend) error { + _, err := backend.SelectBlurayCandidate(context.Background(), "session", blurayCandidateSelectionRequest{ + CorrelationID: "attempt-1", + Path: path, + ReleaseID: "candidate", + }) + return err + }, + }, + { + name: "reset metadata", + capabilities: selectionOnly, + want: "metadata capability unavailable", + call: func(backend *Backend) error { + _, err := backend.ResetMetadata(context.Background(), "session", metadataPreparationRequest{CorrelationID: "attempt-1", Path: path}) + return err + }, + }, + { + name: "preparation", + capabilities: metadataOnly, + want: "preparation capability unavailable", + call: func(backend *Backend) error { + _, err := backend.FetchPreparation("session", path, api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, nil) + return err + }, + }, + { + name: "description preview", + capabilities: metadataOnly, + want: "description capability unavailable", + call: func(backend *Backend) error { + _, err := backend.FetchDescriptionBuilder(api.ReleaseRef{SourcePath: path, Generation: 1}, nil) + return err + }, + }, + { + name: "description render", + capabilities: metadataOnly, + want: "description capability unavailable", + call: func(backend *Backend) error { + _, err := backend.RenderDescription("text") + return err + }, + }, + { + name: "description save", + capabilities: metadataOnly, + want: "description capability unavailable", + call: func(backend *Backend) error { + _, err := backend.SaveDescriptionOverride(api.ReleaseRef{SourcePath: path, Generation: 1}, "group", "text", nil) + return err + }, + }, + { + name: "playlist discover", + capabilities: metadataOnly, + want: "playlist capability unavailable", + call: func(backend *Backend) error { + _, err := backend.DiscoverPlaylists(context.Background(), path) + return err + }, + }, + { + name: "screenshot plan", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + _, err := backend.FetchScreenshotPlan(api.ReleaseRef{SourcePath: path, Generation: 1}) + return err + }, + }, + { + name: "screenshot generation", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + _, err := backend.GenerateScreenshots(api.ReleaseRef{SourcePath: path, Generation: 1}, nil, api.ScreenshotPurposeFinal) + return err + }, + }, + { + name: "screenshot preview", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + _, err := backend.PreviewScreenshotFrame(api.ReleaseRef{SourcePath: path, Generation: 1}, 1) + return err + }, + }, + { + name: "screenshot delete", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + return backend.DeleteScreenshot(api.ReleaseRef{SourcePath: path, Generation: 1}, "image.png") + }, + }, + { + name: "tracker image delete", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + return backend.DeleteTrackerImageURL(api.ReleaseRef{SourcePath: path, Generation: 1}, "https://img.example.com/image.png") + }, + }, + { + name: "screenshot selection save", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + return backend.SaveFinalScreenshotSelections(api.ReleaseRef{SourcePath: path, Generation: 1}, nil) + }, + }, + { + name: "menu image import", + capabilities: metadataOnly, + want: "screenshot capability unavailable", + call: func(backend *Backend) error { + return backend.ImportMenuImages(api.ReleaseRef{SourcePath: path, Generation: 1}, nil) + }, + }, + { + name: "upload candidates", + capabilities: metadataOnly, + want: "hosted image capability unavailable", + call: func(backend *Backend) error { + _, err := backend.ListUploadCandidates(api.ReleaseRef{SourcePath: path, Generation: 1}) + return err + }, + }, + { + name: "uploaded images", + capabilities: metadataOnly, + want: "hosted image capability unavailable", + call: func(backend *Backend) error { + _, err := backend.ListUploadedImages(api.ReleaseRef{SourcePath: path, Generation: 1}) + return err + }, + }, + { + name: "image upload", + capabilities: metadataOnly, + want: "hosted image capability unavailable", + call: func(backend *Backend) error { + _, err := backend.UploadImages( + context.Background(), + "session-1", + "image-upload-1", + api.ReleaseRef{SourcePath: path, Generation: 1}, + nil, + "host", + []api.ScreenshotImage{{Path: "image.png"}}, + ) + return err + }, + }, + { + name: "uploaded image delete", + capabilities: metadataOnly, + want: "hosted image capability unavailable", + call: func(backend *Backend) error { + return backend.DeleteUploadedImage(api.ReleaseRef{SourcePath: path, Generation: 1}, "image.png", "host") + }, + }, + { + name: "DVD image list", + capabilities: metadataOnly, + want: "DVD menu capability unavailable", + call: func(backend *Backend) error { + _, err := backend.ListDVDMenuScreenshots(api.ReleaseRef{SourcePath: path, Generation: 1}) + return err + }, + }, + { + name: "DVD image delete", + capabilities: metadataOnly, + want: "DVD menu capability unavailable", + call: func(backend *Backend) error { + return backend.DeleteDVDMenuScreenshot(api.ReleaseRef{SourcePath: path, Generation: 1}, "image.png") + }, + }, + { + name: "history delete", + capabilities: metadataOnly, + want: "history capability unavailable", + call: func(backend *Backend) error { + return backend.DeleteHistoryRelease(path) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + backend := &Backend{capabilities: tt.capabilities, hub: newEventHub()} + err := tt.call(backend) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error: got %v want containing %q", err, tt.want) + } + }) } } @@ -430,6 +626,32 @@ func (c *backendSnapshotGuardCore) FetchMetadataPreview(_ context.Context, req a return api.MetadataPreview{}, nil } +func (*backendSnapshotGuardCore) FetchAcceptedMetadataPreview(_ context.Context, ref api.ReleaseRef) (api.MetadataPreview, error) { + return api.MetadataPreview{SourcePath: ref.SourcePath}, nil +} + +func (*backendSnapshotGuardCore) CheckAcceptedDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil +} + +func (c *backendSnapshotGuardCore) FetchPreparationPreview(_ context.Context, req api.Request) (api.PreparationPreview, error) { + c.check(req, "FetchPreparationPreview") + return api.PreparationPreview{}, nil +} + +func (c *backendSnapshotGuardCore) FetchAcceptedPreparationPreview(_ context.Context, input api.DescriptionInput) (api.PreparationPreview, error) { + c.check(api.Request{Options: input.Options}, "FetchAcceptedPreparationPreview") + return api.PreparationPreview{}, nil +} + +func (*backendSnapshotGuardCore) BuildUploadReview(context.Context, api.Request) (api.UploadReview, error) { + return api.UploadReview{}, nil +} + +func (*backendSnapshotGuardCore) ReviewAcceptedUpload(context.Context, api.UploadReviewInput) (api.ReviewedUpload, error) { + return api.ReviewedUpload{}, nil +} + func (c *backendSnapshotGuardCore) ListUploadCandidates(_ context.Context, req api.Request) ([]api.ScreenshotImage, error) { c.check(req, "ListUploadCandidates") return nil, nil @@ -445,14 +667,90 @@ func (c *backendSnapshotGuardCore) UploadImages(_ context.Context, req api.Reque return api.UploadImagesResult{}, nil } +func (*backendSnapshotGuardCore) ListAcceptedUploadCandidates(context.Context, api.ImageHostingInput) ([]api.ScreenshotImage, error) { + return nil, nil +} + +func (*backendSnapshotGuardCore) ListAcceptedUploadedImages(context.Context, api.ImageHostingInput) ([]api.UploadedImageLink, error) { + return nil, nil +} + +func (*backendSnapshotGuardCore) UploadAcceptedImages(context.Context, api.ImageHostingInput, []api.ScreenshotImage) (api.UploadImagesResult, error) { + return api.UploadImagesResult{}, nil +} + +func (*backendSnapshotGuardCore) DeleteAcceptedUploadedImage(context.Context, api.ImageHostingInput, string, string) error { + return nil +} + +func (*backendSnapshotGuardCore) DeleteUploadedImage(context.Context, api.Request, string, string) error { + return nil +} + +func (*backendSnapshotGuardCore) CaptureDVDMenus(context.Context, api.Request) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, nil +} + +func (*backendSnapshotGuardCore) CaptureAcceptedDVDMenus(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, nil +} + +func (c *backendSnapshotGuardCore) ListDVDMenuScreenshots(_ context.Context, req api.Request) ([]api.ScreenshotImage, error) { + c.check(req, "ListDVDMenuScreenshots") + return nil, nil +} + +func (c *backendSnapshotGuardCore) DeleteDVDMenuScreenshot(_ context.Context, req api.Request, _ string) error { + c.check(req, "DeleteDVDMenuScreenshot") + return nil +} + +func (*backendSnapshotGuardCore) ListAcceptedDVDMenuScreenshots(context.Context, api.MediaPlanInput) ([]api.ScreenshotImage, error) { + return nil, nil +} + +func (*backendSnapshotGuardCore) DeleteAcceptedDVDMenuScreenshot(context.Context, api.MediaPlanInput, string) error { + return nil +} + +func (c *backendSnapshotGuardCore) FetchDescriptionBuilderPreview(_ context.Context, req api.Request) (api.DescriptionBuilderPreview, error) { + c.check(req, "FetchDescriptionBuilderPreview") + return api.DescriptionBuilderPreview{}, nil +} + +func (c *backendSnapshotGuardCore) FetchAcceptedDescriptionBuilderPreview(_ context.Context, input api.DescriptionInput) (api.DescriptionBuilderPreview, error) { + c.check(api.Request{Options: input.Options}, "FetchAcceptedDescriptionBuilderPreview") + return api.DescriptionBuilderPreview{}, nil +} + +func (*backendSnapshotGuardCore) FetchAcceptedDescriptionBuilderGroupPreview(context.Context, api.DescriptionInput) (api.DescriptionBuilderGroup, error) { + return api.DescriptionBuilderGroup{}, nil +} + +func (*backendSnapshotGuardCore) SaveAcceptedDescriptionOverride(context.Context, api.DescriptionInput, string) (api.DescriptionBuilderGroup, error) { + return api.DescriptionBuilderGroup{}, nil +} + +func (*backendSnapshotGuardCore) FetchDescriptionBuilderGroupPreview(context.Context, api.Request) (api.DescriptionBuilderGroup, error) { + return api.DescriptionBuilderGroup{}, nil +} + +func (*backendSnapshotGuardCore) RenderDescription(context.Context, string) (string, error) { + return "", nil +} + +func (*backendSnapshotGuardCore) SaveDescriptionOverride(context.Context, api.Request, string) (api.DescriptionBuilderGroup, error) { + return api.DescriptionBuilderGroup{}, nil +} + func (c *backendSnapshotGuardCore) RunUploadPrepared(_ context.Context, req api.Request) (api.Result, error) { c.check(req, "RunUploadPrepared") return api.Result{}, nil } -func (c *backendSnapshotGuardCore) ExportGUICachedPreparedMeta(_ context.Context, req api.Request) (api.PreparedMetadata, bool, error) { - c.check(req, "ExportGUICachedPreparedMeta") - return api.PreparedMetadata{}, false, nil +func (c *backendSnapshotGuardCore) RunAcceptedUpload(_ context.Context, plan api.UploadExecutionPlan) (api.Result, error) { + c.check(api.Request{Options: plan.Input.Options}, "RunAcceptedUpload") + return api.Result{}, nil } func TestBackendRequestsUseSingleRuntimeSnapshot(t *testing.T) { @@ -465,10 +763,10 @@ func TestBackendRequestsUseSingleRuntimeSnapshot(t *testing.T) { cfgB := cfgA cfgB.ScreenshotHandling.Screens = 2 backend := &Backend{ - cfg: cfgA, - core: &backendSnapshotGuardCore{wantScreens: 1, errs: errs}, - repo: repo, - hub: newEventHub(), + cfg: cfgA, + capabilities: webTestCapabilities(&backendSnapshotGuardCore{wantScreens: 1, errs: errs}), + repo: repo, + hub: newEventHub(), } stop := make(chan struct{}) @@ -481,9 +779,9 @@ func TestBackendRequestsUseSingleRuntimeSnapshot(t *testing.T) { default: } if i%2 == 0 { - backend.replaceRuntime(cfgB, &backendSnapshotGuardCore{wantScreens: 2, errs: errs}, nil) + backend.replaceRuntime(cfgB, webTestCapabilities(&backendSnapshotGuardCore{wantScreens: 2, errs: errs}), nil) } else { - backend.replaceRuntime(cfgA, &backendSnapshotGuardCore{wantScreens: 1, errs: errs}, nil) + backend.replaceRuntime(cfgA, webTestCapabilities(&backendSnapshotGuardCore{wantScreens: 1, errs: errs}), nil) } } }) @@ -493,19 +791,43 @@ func TestBackendRequestsUseSingleRuntimeSnapshot(t *testing.T) { }) for range 200 { - if _, err := backend.FetchMetadata("session", "C:\\releases\\Example.mkv", "", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, false); err != nil { + release := api.ReleaseRef{SourcePath: "C:\\releases\\Example.mkv", Generation: 1} + if _, err := backend.FetchMetadata(context.Background(), "session", metadataPreparationRequest{ + CorrelationID: "attempt-race", + Path: "C:\\releases\\Example.mkv", + }); err != nil { t.Fatalf("fetch metadata: %v", err) } - if _, err := backend.UploadImages("C:\\releases\\Example.mkv", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, "host", []api.ScreenshotImage{{Path: "image.jpg"}}); err != nil { + if _, err := backend.FetchPreparation("session", release.SourcePath, api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, nil); err != nil { + t.Fatalf("fetch preparation: %v", err) + } + if _, err := backend.UploadImages( + context.Background(), + "session-1", + "image-upload-1", + release, + nil, + "host", + []api.ScreenshotImage{{Path: "image.jpg"}}, + ); err != nil { t.Fatalf("upload images: %v", err) } - if _, err := backend.ListUploadCandidates("C:\\releases\\Example.mkv", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}); err != nil { + if _, err := backend.ListUploadCandidates(release); err != nil { t.Fatalf("list upload candidates: %v", err) } - if _, err := backend.ListUploadedImages("C:\\releases\\Example.mkv", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}); err != nil { + if _, err := backend.ListUploadedImages(release); err != nil { t.Fatalf("list uploaded images: %v", err) } - _, _ = backend.FetchTrackerDryRun("session", "C:\\releases\\Example.mkv", api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, nil, nil, nil, nil, false, false, "") + if _, err := backend.FetchDescriptionBuilder(release, nil); err != nil { + t.Fatalf("fetch description builder: %v", err) + } + if _, err := backend.ListDVDMenuScreenshots(release); err != nil { + t.Fatalf("list DVD menu screenshots: %v", err) + } + if err := backend.DeleteDVDMenuScreenshot(release, "image.png"); err != nil { + t.Fatalf("delete DVD menu screenshot: %v", err) + } + _, _ = backend.FetchTrackerDryRun(context.Background(), "session", "", release, nil, nil, nil, nil, false, "") select { case err := <-errs: t.Fatal(err) @@ -514,27 +836,27 @@ func TestBackendRequestsUseSingleRuntimeSnapshot(t *testing.T) { } } -func TestBackendRunSingleTrackerUploadUsesJobUploadOptionsSnapshot(t *testing.T) { +func TestWebUploadRunnerUsesRequestUploadOptionsSnapshot(t *testing.T) { t.Parallel() errs := make(chan error, 1) repoPath := filepath.Join(t.TempDir(), "web-upload-job.db") jobCfg := backendConfigTestConfig(repoPath) jobCfg.ScreenshotHandling.Screens = 1 - currentCfg := jobCfg - currentCfg.ScreenshotHandling.Screens = 2 - backend := &Backend{cfg: currentCfg} - job := &trackerUploadJob{ - sourcePath: "C:\\releases\\Example.mkv", - uploadOptions: buildRunUploadOptions(jobCfg, runOptions{}), - runOptions: runOptions{}, - core: &backendSnapshotGuardCore{wantScreens: 1, errs: errs}, - descriptionGroups: nil, - trackers: []string{"BTN"}, - questionnaireAnswers: map[string]map[string]string{}, - } - - if _, err := backend.runSingleTrackerUpload(context.Background(), job, "BTN"); err != nil { + coreSvc := &backendSnapshotGuardCore{wantScreens: 1, errs: errs} + request := api.Request{ + SourcePath: "C:\\releases\\Example.mkv", + Options: buildRunUploadOptions(jobCfg, runOptions{}), + Trackers: []string{"BTN"}, + } + runner := webUploadRunner{core: coreSvc, generationTarget: coreSvc} + + if _, err := runner.RunUpload(context.Background(), api.UploadExecutionPlan{Input: api.UploadReviewInput{ + Release: api.ReleaseRef{SourcePath: request.SourcePath}, + Trackers: request.Trackers, + Options: request.Options, + }, + }); err != nil { t.Fatalf("run single tracker upload: %v", err) } select { @@ -555,7 +877,7 @@ func TestBackendSaveConfigAcceptsSameDatabasePathAlias(t *testing.T) { hub: newEventHub(), } t.Cleanup(func() { - if coreSvc := backend.currentCore(); coreSvc != nil { + if coreSvc := backend.coreOwner; coreSvc != nil { _ = coreSvc.Close() } if logger := backend.currentLogger(); logger != nil { @@ -579,7 +901,7 @@ func TestBackendSaveConfigAcceptsSameDatabasePathAlias(t *testing.T) { } } -func TestBackendSaveConfigRejectsDifferentDatabasePath(t *testing.T) { +func TestBackendSaveConfigForcesOpenDatabasePath(t *testing.T) { t.Parallel() repo, repoPath := openBackendConfigTestRepo(t, "backend-save-dbpath-change.db") @@ -589,20 +911,29 @@ func TestBackendSaveConfigRejectsDifferentDatabasePath(t *testing.T) { repo: repo, hub: newEventHub(), } + t.Cleanup(func() { + if coreSvc := backend.coreOwner; coreSvc != nil { + _ = coreSvc.Close() + } + if logger := backend.currentLogger(); logger != nil { + _ = logger.Close() + } + }) updated := initial updated.MainSettings.DBPath = filepath.Join(t.TempDir(), "different.db") + updated.Metadata.SkipAutoTorrent = true payload, err := config.ExportToJSON(&updated) if err != nil { t.Fatalf("export config: %v", err) } - err = backend.SaveConfig(payload) - if err == nil { - t.Fatal("expected DBPath change rejection") + if err := backend.SaveConfig(payload); err != nil { + t.Fatalf("save config: %v", err) } - if !strings.Contains(err.Error(), "requires restart") { - t.Fatalf("expected restart error, got %v", err) + runtimeCfg := backend.currentConfig() + if runtimeCfg.MainSettings.DBPath != repoPath || !runtimeCfg.Metadata.SkipAutoTorrent { + t.Fatalf("runtime config = %#v", runtimeCfg) } } @@ -632,7 +963,7 @@ func TestBackendSaveConfigAppliesRuntimeConfigImmediately(t *testing.T) { hub: newEventHub(), } t.Cleanup(func() { - if coreSvc := backend.currentCore(); coreSvc != nil { + if coreSvc := backend.coreOwner; coreSvc != nil { _ = coreSvc.Close() } if logger := backend.currentLogger(); logger != nil { @@ -660,7 +991,7 @@ func TestBackendSaveConfigAppliesRuntimeConfigImmediately(t *testing.T) { if runtimeCfg.ScreenshotHandling.Screens != 5 { t.Fatalf("expected screenshots=5, got %d", runtimeCfg.ScreenshotHandling.Screens) } - if backend.currentCore() == nil { + if backend.coreOwner == nil { t.Fatal("expected runtime core to be rebuilt") } options := buildRunUploadOptions(runtimeCfg, runOptions{}) @@ -688,7 +1019,7 @@ func TestBackendSaveConfigAfterInvalidStartupMigratesLegacyCookies(t *testing.T) t.Cleanup(func() { _ = backend.Close() }) - if backend.currentCore() != nil { + if backend.coreOwner != nil { t.Fatal("expected invalid startup to leave core disabled") } @@ -778,7 +1109,7 @@ func TestBackendLogStreamContinuesAcrossSaveConfigRuntimeReplacement(t *testing. } t.Cleanup(func() { backend.stopAllLogStreams() - if coreSvc := backend.currentCore(); coreSvc != nil { + if coreSvc := backend.coreOwner; coreSvc != nil { _ = coreSvc.Close() } if logger := backend.currentLogger(); logger != nil { @@ -869,7 +1200,7 @@ func TestBackendLogStreamContinuesAcrossImportConfigRuntimeReplacement(t *testin } t.Cleanup(func() { backend.stopAllLogStreams() - if coreSvc := backend.currentCore(); coreSvc != nil { + if coreSvc := backend.coreOwner; coreSvc != nil { _ = coreSvc.Close() } if logger := backend.currentLogger(); logger != nil { @@ -946,7 +1277,7 @@ func TestBackendSaveConfigRejectsInvalidEnvRuntimeConfig(t *testing.T) { if got := backend.currentConfig().ScreenshotHandling.Screens; got != 1 { t.Fatalf("expected runtime config to remain unchanged, got screens=%d", got) } - if backend.currentCore() != nil { + if backend.coreOwner != nil { t.Fatal("expected runtime core not to be rebuilt") } if _, loadErr := config.LoadFromDatabase(context.Background(), repo); loadErr == nil { @@ -988,7 +1319,7 @@ func TestBackendSaveConfigBuildRuntimeFailureDoesNotPersist(t *testing.T) { if got := backend.currentConfig().Metadata.SkipAutoTorrent; got { t.Fatal("expected runtime config to remain unchanged") } - if backend.currentCore() != nil { + if backend.coreOwner != nil { t.Fatal("expected runtime core not to be rebuilt") } } @@ -1024,7 +1355,7 @@ func TestBackendImportConfigBuildRuntimeFailureDoesNotPersist(t *testing.T) { if got := backend.currentConfig().Metadata.SkipAutoTorrent; got { t.Fatal("expected runtime config to remain unchanged") } - if backend.currentCore() != nil { + if backend.coreOwner != nil { t.Fatal("expected runtime core not to be rebuilt") } } @@ -1118,13 +1449,14 @@ func TestBackendSaveConfigSyncsUsableWebAuthBeforeSave(t *testing.T) { assertCookieAuthStatePresent(t, repo) } -func TestBackendSaveConfigLeavesConfigUnchangedWhenRepositorySaveFailsAfterCookieSync(t *testing.T) { +func TestBackendSaveConfigRollsBackCookieMaintenanceWhenRepositorySaveFails(t *testing.T) { t.Parallel() repo, repoPath := openBackendConfigTestRepo(t, "backend-save-cookie-rollback.db") if err := BootstrapAuthFile(repoPath, "tester", "very-secure-password"); err != nil { t.Fatalf("BootstrapAuthFile: %v", err) } + legacyPath := writeBackendLegacyCookieFile(t, repoPath, "EXAMPLE", `{"session":"cookie-value"}`) installBackendFailMainSettingsTrigger(t, repo) initial := backendConfigTestConfig(repoPath) backend := &Backend{ @@ -1145,117 +1477,20 @@ func TestBackendSaveConfigLeavesConfigUnchangedWhenRepositorySaveFailsAfterCooki t.Fatal("expected save config to fail") } - assertConfigRowsAbsent(t, repo) - assertCookieAuthStatePresent(t, repo) - if got := backend.currentConfig().Metadata.SkipAutoTorrent; got { - t.Fatal("expected runtime config not to be applied after failed config save") - } -} - -func TestBackendSaveConfigHardCookieMigrationErrorDoesNotInstallRuntime(t *testing.T) { - t.Parallel() - - repo, repoPath := openBackendConfigTestRepo(t, "backend-save-cookie-migration-hard-error.db") - initial := backendConfigTestConfig(repoPath) - if err := config.SaveToDatabase(context.Background(), &initial, repo); err != nil { - t.Fatalf("save initial config: %v", err) - } - backend := &Backend{ - cfg: initial, - repo: repo, - hub: newEventHub(), - sharedCookieMigrator: func(context.Context, string, api.Logger) error { - return errors.New("forced migration failure") - }, - } - - updated := initial - updated.Metadata.SkipAutoTorrent = true - payload, err := config.ExportToJSON(&updated) - if err != nil { - t.Fatalf("export config: %v", err) + assertFailedConfigSaveRowsAbsent(t, repo) + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("expected legacy cookie file to remain after failed activation: %v", err) } - - err = backend.SaveConfig(payload) - if err == nil { - t.Fatal("expected hard cookie migration error") + var cookieCount int + if err := repo.RawDB().QueryRowContext(context.Background(), `SELECT COUNT(*) FROM tracker_cookies`).Scan(&cookieCount); err != nil { + t.Fatalf("count cookies after failed activation: %v", err) } - if !strings.Contains(err.Error(), "forced migration failure") { - t.Fatalf("expected forced migration failure, got %v", err) + if cookieCount != 0 { + t.Fatalf("expected cookie writes to roll back, got count=%d", cookieCount) } - assertStoredSkipAutoTorrentUnset(t, repo) if got := backend.currentConfig().Metadata.SkipAutoTorrent; got { - t.Fatal("expected runtime config not to be applied after hard migration failure") - } - if backend.currentCore() != nil { - t.Fatal("expected runtime core not to be installed after hard migration failure") - } -} - -func TestBackendImportConfigHardCookieMigrationErrorPropagates(t *testing.T) { - t.Parallel() - - repo, repoPath := openBackendConfigTestRepo(t, "backend-import-cookie-migration-hard-error.db") - initial := backendConfigTestConfig(repoPath) - if err := config.SaveToDatabase(context.Background(), &initial, repo); err != nil { - t.Fatalf("save initial config: %v", err) - } - backend := &Backend{ - cfg: initial, - repo: repo, - hub: newEventHub(), - sharedCookieMigrator: func(context.Context, string, api.Logger) error { - return errors.New("forced migration failure") - }, - } - - imported := initial - imported.Metadata.SkipAutoTorrent = true - content := exportConfigYAMLString(t, &imported) - - _, _, err := backend.ImportConfig("config.yaml", content) - if err == nil { - t.Fatal("expected hard cookie migration error") - } - if !strings.Contains(err.Error(), "forced migration failure") { - t.Fatalf("expected forced migration failure, got %v", err) - } - assertStoredSkipAutoTorrentUnset(t, repo) - if got := backend.currentConfig().Metadata.SkipAutoTorrent; got { - t.Fatal("expected runtime config not to be applied after imported hard migration failure") - } - if backend.currentCore() != nil { - t.Fatal("expected runtime core not to be installed after imported hard migration failure") - } -} - -func TestBackendSaveConfigRepositoryRejectsAuthChangeAfterValidation(t *testing.T) { - t.Parallel() - - repo, repoPath := openBackendConfigTestRepo(t, "backend-save-auth-drift.db") - if err := BootstrapAuthFile(repoPath, "tester", "very-secure-password"); err != nil { - t.Fatalf("BootstrapAuthFile: %v", err) - } - if err := validateCookieAuthMaterial(repoPath); err != nil { - t.Fatalf("prevalidate auth material: %v", err) - } - if err := os.WriteFile(AuthFilePath(repoPath), []byte(`{`), 0o600); err != nil { - t.Fatalf("replace auth file: %v", err) - } - initial := backendConfigTestConfig(repoPath) - backend := &Backend{ - cfg: initial, - repo: repo, - hub: newEventHub(), - } - - updated := initial - updated.Metadata.SkipAutoTorrent = true - err := backend.saveConfigToRepository(context.Background(), &updated, repoPath) - if err == nil { - t.Fatal("expected auth drift to malformed helper to fail") + t.Fatal("expected runtime config not to be applied after failed config save") } - assertFailedConfigSaveRowsAbsent(t, repo) } func openBackendConfigTestRepo(t *testing.T, name string) (*db.SQLiteRepository, string) { @@ -1329,19 +1564,6 @@ func assertFailedConfigSaveRowsAbsent(t *testing.T, repo *db.SQLiteRepository) { } } -func assertConfigRowsAbsent(t *testing.T, repo *db.SQLiteRepository) { - t.Helper() - - var data string - err := repo.RawDB().QueryRowContext(context.Background(), - `SELECT data FROM config_settings WHERE section = ?`, - "MainSettings", - ).Scan(&data) - if !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("expected MainSettings to be absent after failed save, got row=%q err=%v", data, err) - } -} - func expectLogStreamMessage(t *testing.T, events <-chan serverEvent, streamID string, want string) { t.Helper() @@ -1579,7 +1801,7 @@ func TestBackendExportConfigAuthorizesAgainstExportSnapshotDBPath(t *testing.T) t.Fatalf("auth DBPath: got %q want %q", authDBPath, pathB) } - backend.replaceRuntime(cfgA, nil, nil) + backend.replaceRuntime(cfgA, CoreCapabilities{}, nil) allowPlaintext, err := backend.allowUnencryptedExport(authDBPath) if err != nil { t.Fatalf("allow unencrypted export: %v", err) diff --git a/internal/webserver/base_path_test.go b/internal/webserver/base_path_test.go index f33c5ef2..29074710 100644 --- a/internal/webserver/base_path_test.go +++ b/internal/webserver/base_path_test.go @@ -16,15 +16,51 @@ func TestNormalizeBaseURLAcceptsCanonicalInputs(t *testing.T) { raw string want string }{ - {name: "empty", raw: "", want: ""}, - {name: "root", raw: "/", want: ""}, - {name: "path without slash", raw: "upbrr", want: "/upbrr"}, - {name: "path with slash", raw: "/upbrr/", want: "/upbrr"}, - {name: "path query fragment stripped", raw: "/upbrr/?token=secret#frag", want: "/upbrr"}, - {name: "absolute https", raw: " https://example.test/upbrr/ ", want: "https://example.test/upbrr/"}, - {name: "absolute http", raw: "http://example.test/upbrr", want: "http://example.test/upbrr/"}, - {name: "absolute ipv6", raw: "http://[::1]:7480/upbrr?token=secret#frag", want: "http://[::1]:7480/upbrr/"}, - {name: "absolute root query stripped", raw: "https://example.test/?token=secret#frag", want: "https://example.test/"}, + { + name: "empty", + raw: "", + want: "", + }, + { + name: "root", + raw: "/", + want: "", + }, + { + name: "path without slash", + raw: "upbrr", + want: "/upbrr", + }, + { + name: "path with slash", + raw: "/upbrr/", + want: "/upbrr", + }, + { + name: "path query fragment stripped", + raw: "/upbrr/?token=secret#frag", + want: "/upbrr", + }, + { + name: "absolute https", + raw: " https://example.test/upbrr/ ", + want: "https://example.test/upbrr/", + }, + { + name: "absolute http", + raw: "http://example.test/upbrr", + want: "http://example.test/upbrr/", + }, + { + name: "absolute ipv6", + raw: "http://[::1]:7480/upbrr?token=secret#frag", + want: "http://[::1]:7480/upbrr/", + }, + { + name: "absolute root query stripped", + raw: "https://example.test/?token=secret#frag", + want: "https://example.test/", + }, } for _, tc := range cases { @@ -49,16 +85,56 @@ func TestNormalizeBaseURLRejectsInvalidInputs(t *testing.T) { raw string want string }{ - {name: "protocol relative", raw: "//example.test/upbrr", want: "protocol-relative"}, - {name: "javascript", raw: "javascript:alert(1)", want: "http or https"}, - {name: "file", raw: "file:///tmp/upbrr", want: "http or https"}, - {name: "ftp", raw: "ftp://example.test/upbrr", want: "http or https"}, - {name: "missing host", raw: "https:/upbrr", want: "must include a host"}, - {name: "userinfo", raw: "https://user@example.test/upbrr", want: "userinfo"}, - {name: "path traversal", raw: "/upbrr/../admin", want: "path traversal"}, - {name: "encoded traversal", raw: "/upbrr/%2e%2e/admin", want: "path traversal"}, - {name: "query only", raw: "?base=/upbrr", want: "must include a path"}, - {name: "fragment only", raw: "#upbrr", want: "must include a path"}, + { + name: "protocol relative", + raw: "//example.test/upbrr", + want: "protocol-relative", + }, + { + name: "javascript", + raw: "javascript:alert(1)", + want: "http or https", + }, + { + name: "file", + raw: "file:///tmp/upbrr", + want: "http or https", + }, + { + name: "ftp", + raw: "ftp://example.test/upbrr", + want: "http or https", + }, + { + name: "missing host", + raw: "https:/upbrr", + want: "must include a host", + }, + { + name: "userinfo", + raw: "https://user@example.test/upbrr", + want: "userinfo", + }, + { + name: "path traversal", + raw: "/upbrr/../admin", + want: "path traversal", + }, + { + name: "encoded traversal", + raw: "/upbrr/%2e%2e/admin", + want: "path traversal", + }, + { + name: "query only", + raw: "?base=/upbrr", + want: "must include a path", + }, + { + name: "fragment only", + raw: "#upbrr", + want: "must include a path", + }, } for _, tc := range cases { @@ -139,13 +215,41 @@ func TestExternalBasePathNormalizesBaseURL(t *testing.T) { raw string want string }{ - {name: "empty", raw: "", want: ""}, - {name: "root absolute url", raw: "https://example.test/", want: ""}, - {name: "absolute url path", raw: " https://example.test/upbrr/ ", want: "/upbrr"}, - {name: "path with slash", raw: "/upbrr/", want: "/upbrr"}, - {name: "path without slash", raw: "upbrr", want: "/upbrr"}, - {name: "nested path", raw: "https://example.test/tools/upbrr/?token=ignored", want: "/tools/upbrr"}, - {name: "query only", raw: "https://example.test?token=ignored", want: ""}, + { + name: "empty", + raw: "", + want: "", + }, + { + name: "root absolute url", + raw: "https://example.test/", + want: "", + }, + { + name: "absolute url path", + raw: " https://example.test/upbrr/ ", + want: "/upbrr", + }, + { + name: "path with slash", + raw: "/upbrr/", + want: "/upbrr", + }, + { + name: "path without slash", + raw: "upbrr", + want: "/upbrr", + }, + { + name: "nested path", + raw: "https://example.test/tools/upbrr/?token=ignored", + want: "/tools/upbrr", + }, + { + name: "query only", + raw: "https://example.test?token=ignored", + want: "", + }, } for _, tc := range cases { @@ -166,10 +270,26 @@ func TestJoinBasePath(t *testing.T) { suffix string want string }{ - {base: "", suffix: "/api/events", want: "/api/events"}, - {base: "/", suffix: "/api/events", want: "/api/events"}, - {base: "/upbrr", suffix: "/api/events", want: "/upbrr/api/events"}, - {base: "/upbrr/", suffix: "api/events", want: "/upbrr/api/events"}, + { + base: "", + suffix: "/api/events", + want: "/api/events", + }, + { + base: "/", + suffix: "/api/events", + want: "/api/events", + }, + { + base: "/upbrr", + suffix: "/api/events", + want: "/upbrr/api/events", + }, + { + base: "/upbrr/", + suffix: "api/events", + want: "/upbrr/api/events", + }, } for _, tc := range cases { diff --git a/internal/guishared/browse_directory.go b/internal/webserver/browse_directory.go similarity index 90% rename from internal/guishared/browse_directory.go rename to internal/webserver/browse_directory.go index f284904f..672fa477 100644 --- a/internal/guishared/browse_directory.go +++ b/internal/webserver/browse_directory.go @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026, Audionut and the autobrr contributors. // SPDX-License-Identifier: GPL-2.0-or-later -package guishared +package webserver import ( "errors" @@ -14,18 +14,26 @@ import ( "time" "github.com/autobrr/upbrr/internal/filesystem" - "github.com/autobrr/upbrr/internal/pathutil" + pathutil "github.com/autobrr/upbrr/internal/pathing" "github.com/autobrr/upbrr/pkg/api" ) +// BrowseDirectory lists an unrestricted host filesystem location. Folder mode +// returns directories only; file mode also returns recognized video files. func BrowseDirectory(req api.BrowseDirectoryRequest, fallbackPath string) (api.BrowseDirectoryResponse, error) { return browseDirectory(req, fallbackPath, "") } +// BrowseDirectoryWithinRoot lists a host filesystem location constrained to +// rootPath after absolute-path and symlink resolution. An empty root disables +// the constraint. func BrowseDirectoryWithinRoot(req api.BrowseDirectoryRequest, fallbackPath string, rootPath string) (api.BrowseDirectoryResponse, error) { return browseDirectory(req, fallbackPath, rootPath) } +// BrowseDirectoryWithinRoots lists a host filesystem location constrained to +// one of rootPaths. An empty request under multiple roots returns a virtual +// directory containing those roots; an empty root list disables constraints. func BrowseDirectoryWithinRoots(req api.BrowseDirectoryRequest, fallbackPath string, rootPaths []string) (api.BrowseDirectoryResponse, error) { return browseDirectoryWithinRoots(req, fallbackPath, rootPaths) } @@ -288,6 +296,9 @@ func browseWindowsRoots(mode string) api.BrowseDirectoryResponse { } } +// BrowseDirectoryFallback returns dbPath when it names an existing directory, +// otherwise its existing parent directory, or an empty string when neither is +// usable. func BrowseDirectoryFallback(dbPath string) string { trimmed := strings.TrimSpace(dbPath) if trimmed == "" { @@ -306,6 +317,8 @@ func BrowseDirectoryFallback(dbPath string) string { return "" } +// ValidateBrowseSelection requires an existing host path of the requested file +// or directory kind. func ValidateBrowseSelection(path string, wantDir bool) error { trimmed := strings.TrimSpace(path) if trimmed == "" { diff --git a/internal/webserver/browse_test.go b/internal/webserver/browse_test.go index 93c7534c..46df8d75 100644 --- a/internal/webserver/browse_test.go +++ b/internal/webserver/browse_test.go @@ -22,33 +22,6 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) -type stubNativePicker struct { - filePath string - imageFilePaths []string - folderPath string - fileErr error - imageFilesErr error - folderErr error - fileCalls int - imageFilesCalls int - folderCalls int -} - -func (s *stubNativePicker) BrowseFile() (string, error) { - s.fileCalls++ - return s.filePath, s.fileErr -} - -func (s *stubNativePicker) BrowseImageFiles() ([]string, error) { - s.imageFilesCalls++ - return s.imageFilePaths, s.imageFilesErr -} - -func (s *stubNativePicker) BrowseFolder() (string, error) { - s.folderCalls++ - return s.folderPath, s.folderErr -} - func testSessionManager() *sessionManager { return &sessionManager{ ttl: time.Hour, @@ -58,22 +31,6 @@ func testSessionManager() *sessionManager { } } -func testServerWithPicker(picker nativePicker) *Server { - manager := testSessionManager() - manager.sessions["test-session"] = session{ - ID: "test-session", - Username: "tester", - CSRFToken: "test-csrf", - ExpiresAt: time.Now().UTC().Add(time.Hour), - } - return &Server{ - picker: picker, - sessions: manager, - generalLimiter: newFixedWindowLimiter(100, time.Minute), - authLimiter: newFixedWindowLimiter(100, time.Minute), - } -} - func testServerWithBackend(t *testing.T, repo *db.SQLiteRepository, cfg config.Config) *Server { t.Helper() manager := testSessionManager() @@ -140,12 +97,12 @@ func canonicalBrowseTestPath(t *testing.T, path string) string { return resolved } -func newBrowseRequest(path string, host string, remoteAddr string) *http.Request { - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(`{}`)) - req.Host = host - req.RemoteAddr = remoteAddr +func newBrowseRequest() *http.Request { + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/BrowseDirectory", strings.NewReader(`{}`)) + req.Host = "example.com:8080" + req.RemoteAddr = "192.168.1.25:5050" req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://"+host) + req.Header.Set("Origin", "http://example.com:8080") req.Header.Set("X-Csrf-Token", "test-csrf") req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "test-session"}) return req @@ -176,168 +133,6 @@ func TestIsLoopbackHostname(t *testing.T) { } } -func TestHandleAuthStatusIncludesNativeBrowseCapability(t *testing.T) { - store, err := newAuthStore(filepath.Join(t.TempDir(), "state", "db.sqlite")) - if err != nil { - t.Fatalf("newAuthStore: %v", err) - } - server := &Server{ - auth: store, - picker: &stubNativePicker{}, - } - - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/auth/status", nil) - req.Host = "127.0.0.1:8080" - req.RemoteAddr = "127.0.0.1:5050" - - recorder := httptest.NewRecorder() - server.handleAuthStatus(recorder, req, session{}) - - if recorder.Code != http.StatusOK { - t.Fatalf("handleAuthStatus returned %d", recorder.Code) - } - - var payload struct { - NativeBrowseEnabled bool `json:"nativeBrowseEnabled"` - } - if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { - t.Fatalf("unmarshal auth status: %v", err) - } - if !payload.NativeBrowseEnabled { - t.Fatal("expected localhost auth status to advertise native browse support") - } -} - -func TestBrowseFileRouteAllowsLocalhostSessions(t *testing.T) { - picker := &stubNativePicker{filePath: `C:\Media\movie.mkv`} - server := testServerWithPicker(picker) - mux := http.NewServeMux() - server.registerAppRoutes(mux) - - recorder := httptest.NewRecorder() - req := newBrowseRequest("/api/app/BrowseFile", "127.0.0.1:8080", "127.0.0.1:5050") - mux.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("browse file route returned %d", recorder.Code) - } - if picker.fileCalls != 1 { - t.Fatalf("expected picker to be called once, got %d", picker.fileCalls) - } - if got := strings.TrimSpace(recorder.Body.String()); !strings.Contains(got, `C:\\Media\\movie.mkv`) { - t.Fatalf("expected response to include selected path, got %q", got) - } -} - -func TestBrowseImageFilesRouteAllowsLocalhostSessions(t *testing.T) { - picker := &stubNativePicker{imageFilePaths: []string{`C:\Menus\menu1.png`, `C:\Menus\menu2.webp`}} - server := testServerWithPicker(picker) - mux := http.NewServeMux() - server.registerAppRoutes(mux) - - recorder := httptest.NewRecorder() - req := newBrowseRequest("/api/app/BrowseImageFiles", "127.0.0.1:8080", "127.0.0.1:5050") - mux.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("browse image files route returned %d", recorder.Code) - } - if picker.imageFilesCalls != 1 { - t.Fatalf("expected image picker to be called once, got %d", picker.imageFilesCalls) - } - if got := strings.TrimSpace(recorder.Body.String()); !strings.Contains(got, `C:\\Menus\\menu1.png`) || !strings.Contains(got, `C:\\Menus\\menu2.webp`) { - t.Fatalf("expected response to include selected image paths, got %q", got) - } -} - -func TestBrowseFileRouteRejectsRemoteSessions(t *testing.T) { - picker := &stubNativePicker{filePath: `C:\Media\movie.mkv`} - server := testServerWithPicker(picker) - mux := http.NewServeMux() - server.registerAppRoutes(mux) - - req := newBrowseRequest("/api/app/BrowseFile", "example.com:8080", "192.168.1.25:5050") - recorder := httptest.NewRecorder() - mux.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusForbidden { - t.Fatalf("expected forbidden status, got %d", recorder.Code) - } - if picker.fileCalls != 0 { - t.Fatalf("expected picker not to be called, got %d calls", picker.fileCalls) - } - if !strings.Contains(recorder.Body.String(), "localhost web sessions") { - t.Fatalf("expected remote browse error message, got %q", recorder.Body.String()) - } -} - -func TestDevelopmentNoAuthAppRouteAllowsLoopbackWithCSRF(t *testing.T) { - picker := &stubNativePicker{filePath: `C:\Media\movie.mkv`} - server := &Server{ - picker: picker, - generalLimiter: newFixedWindowLimiter(100, time.Minute), - developmentNoAuth: true, - developmentSession: session{ - ID: "dev-no-auth", - Username: "dev", - CSRFToken: "dev-csrf", - ExpiresAt: time.Now().UTC().Add(time.Hour), - }, - } - mux := http.NewServeMux() - server.registerAppRoutes(mux) - - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/BrowseFile", strings.NewReader(`{}`)) - req.Host = "127.0.0.1:7480" - req.RemoteAddr = "127.0.0.1:5050" - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://localhost:5173") - req.Header.Set("X-Csrf-Token", "dev-csrf") - - recorder := httptest.NewRecorder() - mux.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("browse file route returned %d: %s", recorder.Code, recorder.Body.String()) - } - if picker.fileCalls != 1 { - t.Fatalf("expected picker to be called once, got %d", picker.fileCalls) - } -} - -func TestDevelopmentNoAuthAppRouteRejectsMissingCSRF(t *testing.T) { - picker := &stubNativePicker{filePath: `C:\Media\movie.mkv`} - server := &Server{ - picker: picker, - generalLimiter: newFixedWindowLimiter(100, time.Minute), - developmentNoAuth: true, - developmentSession: session{ - ID: "dev-no-auth", - Username: "dev", - CSRFToken: "dev-csrf", - ExpiresAt: time.Now().UTC().Add(time.Hour), - }, - } - mux := http.NewServeMux() - server.registerAppRoutes(mux) - - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/BrowseFile", strings.NewReader(`{}`)) - req.Host = "127.0.0.1:8080" - req.RemoteAddr = "127.0.0.1:5050" - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://127.0.0.1:8080") - - recorder := httptest.NewRecorder() - mux.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusForbidden { - t.Fatalf("expected forbidden status, got %d: %s", recorder.Code, recorder.Body.String()) - } - if picker.fileCalls != 0 { - t.Fatalf("expected picker not to be called, got %d calls", picker.fileCalls) - } -} - func TestBrowseDirectoryRouteAllowsRemoteSessionsAndSortsEntries(t *testing.T) { repo, dbPath := openBrowseTestRepo(t) root := canonicalBrowseTestPath(t, t.TempDir()) @@ -357,7 +152,7 @@ func TestBrowseDirectoryRouteAllowsRemoteSessionsAndSortsEntries(t *testing.T) { mux := http.NewServeMux() server.registerAppRoutes(mux) - req := newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req := newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(root) + `,"mode":"file"}`)) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -401,7 +196,7 @@ func TestBrowseDirectoryRouteRequiresWebBrowsePolicy(t *testing.T) { mux := http.NewServeMux() server.registerAppRoutes(mux) - req := newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req := newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(root) + `,"mode":"folder"}`)) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -615,7 +410,7 @@ func TestBrowseDirectoryRouteHonorsWebAuthBrowseRoot(t *testing.T) { mux := http.NewServeMux() server.registerAppRoutes(mux) - req := newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req := newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":"","mode":"folder"}`)) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -633,7 +428,7 @@ func TestBrowseDirectoryRouteHonorsWebAuthBrowseRoot(t *testing.T) { t.Fatalf("expected no parent above constrained root, got %q", payload.ParentPath) } - req = newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req = newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(outside) + `,"mode":"folder"}`)) recorder = httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -665,7 +460,7 @@ func TestBrowseDirectoryRouteHonorsMultipleWebAuthBrowseRoots(t *testing.T) { mux := http.NewServeMux() server.registerAppRoutes(mux) - req := newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req := newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":"","mode":"folder"}`)) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -683,7 +478,7 @@ func TestBrowseDirectoryRouteHonorsMultipleWebAuthBrowseRoots(t *testing.T) { t.Fatalf("expected both configured roots, got %#v", payload.Entries) } - req = newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req = newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(second) + `,"mode":"folder"}`)) recorder = httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -697,7 +492,7 @@ func TestBrowseDirectoryRouteHonorsMultipleWebAuthBrowseRoots(t *testing.T) { t.Fatalf("expected constrained second root, got %#v", payload) } - req = newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req = newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(outside) + `,"mode":"folder"}`)) recorder = httptest.NewRecorder() mux.ServeHTTP(recorder, req) @@ -739,7 +534,7 @@ func TestBrowseDirectoryRouteRejectsInvalidPath(t *testing.T) { mux := http.NewServeMux() server.registerAppRoutes(mux) - req := newBrowseRequest("/api/app/BrowseDirectory", "example.com:8080", "192.168.1.25:5050") + req := newBrowseRequest() req.Body = io.NopCloser(strings.NewReader(`{"path":` + strconv.Quote(filepath.Join(t.TempDir(), "missing")) + `,"mode":"folder"}`)) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) diff --git a/internal/webserver/canonical_preparation.go b/internal/webserver/canonical_preparation.go new file mode 100644 index 00000000..c5d88628 --- /dev/null +++ b/internal/webserver/canonical_preparation.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "errors" + "fmt" + + "github.com/autobrr/upbrr/internal/preparedrelease" + "github.com/autobrr/upbrr/pkg/api" +) + +var ( + // ErrCanonicalPreparationUnavailable reports a runtime bundle missing the + // canonical prepare/export/import seam. + ErrCanonicalPreparationUnavailable = errors.New("canonical preparation capability unavailable") +) + +// PrepareGeneration creates or reuses one canonical generation for a +// synchronous operation and returns its exact reference. +func PrepareGeneration( + ctx context.Context, + preparer ReleasePreparationCapability, + request api.Request, + intent api.PreparationIntent, +) (api.PrepareResult, api.ReleaseRef, error) { + if capabilityIsNil(preparer) { + return api.PrepareResult{}, api.ReleaseRef{}, ErrCanonicalPreparationUnavailable + } + input, err := api.MapPreparationRequest(request, intent) + if err != nil { + return api.PrepareResult{}, api.ReleaseRef{}, fmt.Errorf("map canonical preparation request: %w", err) + } + result, err := preparer.PrepareRelease(ctx, input) + if err != nil { + return api.PrepareResult{}, api.ReleaseRef{}, fmt.Errorf("prepare canonical generation: %w", err) + } + ref := api.ReleaseRef{SourcePath: result.Release.Source.SourcePath, Generation: result.Release.Generation} + return result, ref, nil +} + +// PrepareAndExportGeneration creates or reuses canonical facts and exports one +// exact opaque generation seed before a job is accepted. +func PrepareAndExportGeneration( + ctx context.Context, + preparer ReleasePreparationCapability, + transfer PreparedGenerationTransfer, + request api.Request, + intent api.PreparationIntent, +) (api.PrepareResult, preparedrelease.Seed, error) { + if capabilityIsNil(preparer) || capabilityIsNil(transfer) { + return api.PrepareResult{}, preparedrelease.Seed{}, ErrCanonicalPreparationUnavailable + } + input, err := api.MapPreparationRequest(request, intent) + if err != nil { + return api.PrepareResult{}, preparedrelease.Seed{}, fmt.Errorf("map canonical preparation request: %w", err) + } + result, err := preparer.PrepareRelease(ctx, input) + if err != nil { + return api.PrepareResult{}, preparedrelease.Seed{}, fmt.Errorf("prepare canonical generation: %w", err) + } + ref := api.ReleaseRef{SourcePath: result.Release.Source.SourcePath, Generation: result.Release.Generation} + seed, err := transfer.ExportReleaseSeed(ctx, ref) + if err != nil { + return api.PrepareResult{}, preparedrelease.Seed{}, fmt.Errorf("export canonical generation: %w", err) + } + return result, seed, nil +} diff --git a/internal/webserver/canonical_preparation_test.go b/internal/webserver/canonical_preparation_test.go new file mode 100644 index 00000000..3f8c481b --- /dev/null +++ b/internal/webserver/canonical_preparation_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestCanonicalPreparationUsesSharedRequestMapper(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + value string + want api.CanonicalCategory + }{ + { + name: "value", + value: "TV", + want: api.CanonicalCategoryTV, + }, + { + name: "explicit clear", + value: "", + want: api.CanonicalCategoryUnknown, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + input, err := api.MapPreparationRequest(api.Request{ + SourcePath: "Example.Release.2026.mkv", + ReleaseNameOverrides: api.ReleaseNameOverrides{Category: &test.value}, + }, api.PreparationIntentPreview) + if err != nil { + t.Fatalf("prepare input: %v", err) + } + if input.Instructions.Category == nil || *input.Instructions.Category != test.want { + t.Fatalf("category = %#v, want %q", input.Instructions.Category, test.want) + } + }) + } +} + +func TestCanonicalPreparationRejectsInvalidCategoryInstruction(t *testing.T) { + t.Parallel() + category := "music" + _, err := api.MapPreparationRequest(api.Request{ + SourcePath: "Example.Release.2026.mkv", + ReleaseNameOverrides: api.ReleaseNameOverrides{Category: &category}, + }, api.PreparationIntentPreview) + if err == nil { + t.Fatal("expected invalid category error") + } +} diff --git a/internal/webserver/capabilities.go b/internal/webserver/capabilities.go new file mode 100644 index 00000000..d4ba9f8a --- /dev/null +++ b/internal/webserver/capabilities.go @@ -0,0 +1,241 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + + "github.com/autobrr/upbrr/internal/core" + "github.com/autobrr/upbrr/internal/preparedrelease" + "github.com/autobrr/upbrr/pkg/api" +) + +// LifecycleOwner is the sole shutdown handle for resources behind a capability bundle. +// Capability interfaces intentionally omit Close so borrowing one never implies ownership. +type LifecycleOwner interface{ Close() error } + +// MetadataCapability prepares metadata previews for WebUI requests. +type MetadataCapability interface { + FetchAcceptedMetadataPreview(context.Context, api.ReleaseRef) (api.MetadataPreview, error) +} + +// ReleasePreparationCapability creates immutable canonical prepared facts. +type ReleasePreparationCapability interface { + PrepareRelease(context.Context, api.PrepareInput) (api.PrepareResult, error) +} + +// SelectionCapability applies an explicit Blu-ray source selection. +type SelectionCapability interface { + SelectBlurayCandidate(context.Context, string, string) (api.MetadataPreview, error) +} + +// PreparationCapability builds preparation views from prepared state. +type PreparationCapability interface { + FetchAcceptedPreparationPreview(context.Context, api.DescriptionInput) (api.PreparationPreview, error) +} + +// UploadReviewCapability builds one review and its execution-only outcomes. +type UploadReviewCapability interface { + ReviewAcceptedUpload(context.Context, api.UploadReviewInput) (api.ReviewedUpload, error) +} + +// DryRunCapability runs tracker payload previews without tracker submission. +type DryRunCapability interface { + RunAcceptedTrackerDryRun(context.Context, api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) +} + +// DuplicateExecutionCapability runs one accepted duplicate-check snapshot. +type DuplicateExecutionCapability interface { + CheckAcceptedDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) +} + +// UploadExecutionCapability runs one accepted reviewed upload snapshot. +type UploadExecutionCapability interface { + RunAcceptedUpload(context.Context, api.UploadExecutionPlan) (api.Result, error) +} + +// ScreenshotCapability owns screenshot planning, generation, selection, and deletion workflows. +type ScreenshotCapability interface { + FetchAcceptedScreenshotPlan(context.Context, api.MediaPlanInput) (api.ScreenshotPlan, error) + GenerateAcceptedScreenshots(context.Context, api.MediaPlanInput, []api.ScreenshotSelection) (api.ScreenshotResult, error) + PreviewAcceptedScreenshotFrame(context.Context, api.MediaPlanInput, float64) (api.ScreenshotPreview, error) + DeleteAcceptedScreenshot(context.Context, api.MediaPlanInput, string) error + DeleteAcceptedTrackerImageURL(context.Context, api.ImageHostingInput, string) error + SaveAcceptedFinalScreenshotSelections(context.Context, api.MediaPlanInput, []api.ScreenshotImage) error + ImportAcceptedMenuImages(context.Context, api.MediaPlanInput, []string) error +} + +// HostedImageCapability owns upload-candidate and hosted-image workflows. +type HostedImageCapability interface { + ListAcceptedUploadCandidates(context.Context, api.ImageHostingInput) ([]api.ScreenshotImage, error) + ListAcceptedUploadedImages(context.Context, api.ImageHostingInput) ([]api.UploadedImageLink, error) + UploadAcceptedImages(context.Context, api.ImageHostingInput, []api.ScreenshotImage) (api.UploadImagesResult, error) + DeleteAcceptedUploadedImage(context.Context, api.ImageHostingInput, string, string) error +} + +// DVDCaptureCapability captures DVD menu images for a prepared request. +type DVDCaptureCapability interface { + CaptureAcceptedDVDMenus(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) +} + +// DVDCapability owns DVD menu capture and prepared-image maintenance. +type DVDCapability interface { + DVDCaptureCapability + ListAcceptedDVDMenuScreenshots(context.Context, api.MediaPlanInput) ([]api.ScreenshotImage, error) + DeleteAcceptedDVDMenuScreenshot(context.Context, api.MediaPlanInput, string) error +} + +// DescriptionCapability previews, renders, and persists prepared-release descriptions. +type DescriptionCapability interface { + FetchAcceptedDescriptionBuilderPreview(context.Context, api.DescriptionInput) (api.DescriptionBuilderPreview, error) + FetchAcceptedDescriptionBuilderGroupPreview(context.Context, api.DescriptionInput) (api.DescriptionBuilderGroup, error) + RenderDescription(context.Context, string) (string, error) + SaveAcceptedDescriptionOverride(context.Context, api.DescriptionInput, string) (api.DescriptionBuilderGroup, error) +} + +// PlaylistCapability discovers BDMV playlists for one preparation source. +type PlaylistCapability interface { + DiscoverPlaylists(context.Context, string) ([]api.PlaylistInfo, error) +} + +// HistoryCapability reads and deletes persisted release history. +type HistoryCapability interface { + ListHistory(context.Context) ([]api.HistoryEntry, error) + GetHistoryOverview(context.Context, string) (api.HistoryOverview, error) + DeleteHistoryRelease(context.Context, string) error + DeleteAllHistoryReleases(context.Context) (int, error) +} + +// PreparedGenerationTransfer moves one exact canonical generation without +// carrying workflow choices or outcomes. +type PreparedGenerationTransfer interface { + ExportReleaseSeed(context.Context, api.ReleaseRef) (preparedrelease.Seed, error) + ImportReleaseSeed(context.Context, preparedrelease.Seed) (api.ReleaseRef, error) +} + +// DiagnosticProbeCapability reports availability of optional runtime tooling. +type DiagnosticProbeCapability interface { + DVDMenuCapability(context.Context) (api.DVDMenuEngineInfo, error) +} + +// CoreCapabilities is the explicit WebUI workflow bundle. Each field is a +// narrow view of one concrete core; lifecycle ownership remains separate. +// Fields are independently optional so consumers and tests can supply only the +// workflows they invoke. Production bindings populate every field. +type CoreCapabilities struct { + Metadata MetadataCapability + ReleasePreparation ReleasePreparationCapability + Selection SelectionCapability + Preparation PreparationCapability + UploadReview UploadReviewCapability + DryRun DryRunCapability + DuplicateExecution DuplicateExecutionCapability + UploadExecution UploadExecutionCapability + Screenshots ScreenshotCapability + HostedImages HostedImageCapability + DVD DVDCapability + Description DescriptionCapability + Playlists PlaylistCapability + History HistoryCapability + PreparedGenerationTransfer PreparedGenerationTransfer + DiagnosticProbe DiagnosticProbeCapability +} + +// Available reports whether at least one capability is installed. +// It does not validate that a bundle contains every capability a caller needs. +func (c CoreCapabilities) Available() bool { + if CapabilityAvailable(c.Metadata) || CapabilityAvailable(c.ReleasePreparation) || CapabilityAvailable(c.Selection) || + CapabilityAvailable(c.Preparation) || CapabilityAvailable(c.UploadReview) || CapabilityAvailable(c.DryRun) || + CapabilityAvailable(c.DuplicateExecution) || + CapabilityAvailable(c.UploadExecution) { + return true + } + if CapabilityAvailable(c.Screenshots) || CapabilityAvailable(c.HostedImages) || CapabilityAvailable(c.DVD) || CapabilityAvailable(c.Description) || + CapabilityAvailable(c.Playlists) || CapabilityAvailable(c.History) { + return true + } + return CapabilityAvailable(c.PreparedGenerationTransfer) || CapabilityAvailable(c.DiagnosticProbe) +} + +// CapabilityAvailable reports whether capability contains a callable value, +// rejecting both nil interfaces and interfaces holding typed nil values. +func CapabilityAvailable(capability any) bool { + return !capabilityIsNil(capability) +} + +// PreparedGenerationReady reports whether canonical generations can be +// prepared and transferred through this bundle. +func (c CoreCapabilities) PreparedGenerationReady() bool { + return CapabilityAvailable(c.ReleasePreparation) && CapabilityAvailable(c.PreparedGenerationTransfer) +} + +// PreparedUploadReady reports whether this bundle can receive state and execute an upload. +func (c CoreCapabilities) PreparedUploadReady() bool { + return c.PreparedGenerationReady() && CapabilityAvailable(c.UploadExecution) +} + +// PreparedDupeReady reports whether this bundle can receive state and execute a dupe check. +func (c CoreCapabilities) PreparedDupeReady() bool { + return c.PreparedGenerationReady() && CapabilityAvailable(c.DuplicateExecution) +} + +// PreparedDVDReady reports whether this bundle can receive state and execute DVD capture. +func (c CoreCapabilities) PreparedDVDReady() bool { + return c.PreparedGenerationReady() && CapabilityAvailable(c.DVD) +} + +// PreparedDryRunReady reports whether the active runtime can execute a dry run. +// Unlike background Jobs, synchronous dry runs already own the prepared +// generation and do not require generation transfer. +func (c CoreCapabilities) PreparedDryRunReady() bool { + return CapabilityAvailable(c.DryRun) +} + +// BindCoreCapabilities exposes svc through every production capability and +// returns svc separately as its lifecycle owner. A nil service yields an empty +// bundle and nil owner; callers must close the returned owner exactly once. +func BindCoreCapabilities(svc *core.Core) (CoreCapabilities, LifecycleOwner) { + if svc == nil { + return CoreCapabilities{}, nil + } + return CoreCapabilities{ + Metadata: svc, + ReleasePreparation: svc, + Selection: svc, + Preparation: svc, + UploadReview: svc, + DryRun: svc, + DuplicateExecution: svc, + UploadExecution: svc, + Screenshots: svc, + HostedImages: svc, + DVD: svc, + Description: svc, + Playlists: svc, + History: svc, + PreparedGenerationTransfer: svc, + DiagnosticProbe: svc, + }, svc +} + +var ( + _ MetadataCapability = (*core.Core)(nil) + _ ReleasePreparationCapability = (*core.Core)(nil) + _ SelectionCapability = (*core.Core)(nil) + _ PreparationCapability = (*core.Core)(nil) + _ UploadReviewCapability = (*core.Core)(nil) + _ DryRunCapability = (*core.Core)(nil) + _ DuplicateExecutionCapability = (*core.Core)(nil) + _ UploadExecutionCapability = (*core.Core)(nil) + _ ScreenshotCapability = (*core.Core)(nil) + _ HostedImageCapability = (*core.Core)(nil) + _ DVDCapability = (*core.Core)(nil) + _ DVDCaptureCapability = (*core.Core)(nil) + _ DescriptionCapability = (*core.Core)(nil) + _ PlaylistCapability = (*core.Core)(nil) + _ HistoryCapability = (*core.Core)(nil) + _ PreparedGenerationTransfer = (*core.Core)(nil) + _ DiagnosticProbeCapability = (*core.Core)(nil) + _ LifecycleOwner = (*core.Core)(nil) +) diff --git a/internal/webserver/config_import_test.go b/internal/webserver/config_import_test.go new file mode 100644 index 00000000..c76378bd --- /dev/null +++ b/internal/webserver/config_import_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" +) + +func TestRouteImportConfigAcceptsEscapedEnvelopeAtRawCap(t *testing.T) { + t.Parallel() + + repo, dbPath := openBrowseTestRepo(t) + if err := authmaterial.BootstrapAuthFile(dbPath, "tester", "long-enough-password"); err != nil { + t.Fatalf("BootstrapAuthFile: %v", err) + } + server := testServerWithBackend(t, repo, config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}) + content := "#" + strings.Repeat("<", configImportMaxBytes-1) + body, err := json.Marshal(map[string]string{ + "FileName": "config.yaml", + "FileContent": content, + }) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + if len(body) <= configImportMaxBytes+1024*1024 { + t.Fatalf("test envelope did not exceed prior cap: got %d", len(body)) + } + + mux := http.NewServeMux() + server.registerAppRoutes(mux) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, configImportRouteRequest(body)) + if recorder.Code != http.StatusOK { + t.Fatalf("expected escaped envelope import to succeed, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestRouteImportConfigRejectsEnvelopeOverRouteCap(t *testing.T) { + t.Parallel() + + repo, dbPath := openBrowseTestRepo(t) + server := testServerWithBackend(t, repo, config.Config{MainSettings: config.MainSettingsConfig{DBPath: dbPath}}) + mux := http.NewServeMux() + server.registerAppRoutes(mux) + body := append(bytes.Repeat([]byte(" "), configImportRequestEnvelopeMaxBytes+1), 'x') + recorder := httptest.NewRecorder() + + mux.ServeHTTP(recorder, configImportRouteRequest(body)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected route cap rejection, got %d: %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "request body too large") { + t.Fatalf("expected body-too-large error, got %s", recorder.Body.String()) + } +} + +func configImportRouteRequest(body []byte) *http.Request { + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/api/app/ImportConfig", + bytes.NewReader(body), + ) + req.Host = "127.0.0.1:8080" + req.RemoteAddr = "127.0.0.1:5050" + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://127.0.0.1:8080") + req.Header.Set("X-Csrf-Token", "test-csrf") + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "test-session"}) + return req +} diff --git a/internal/webserver/dvd_menu_jobs.go b/internal/webserver/dvd_menu_jobs.go index a8e12c85..175d2f32 100644 --- a/internal/webserver/dvd_menu_jobs.go +++ b/internal/webserver/dvd_menu_jobs.go @@ -7,414 +7,124 @@ import ( "context" "errors" "fmt" - "sort" - "strings" - "sync" - "time" - "github.com/autobrr/upbrr/internal/guishared" - "github.com/autobrr/upbrr/internal/redaction" + "github.com/autobrr/upbrr/internal/preparedrelease" "github.com/autobrr/upbrr/pkg/api" ) -const ( - errDVDMenuCaptureRequiresMetadataCache = "DVD menu capture requires metadata preview" - maxCompletedDVDMenuJobs = 200 - dvdMenuSnapshotThrottle = 150 * time.Millisecond -) +type acceptedDVDCapture interface { + CaptureAcceptedDVDMenus(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) +} -// dvdMenuCaptureJob owns one isolated core/logger snapshot and synchronized -// session-visible progress state. -type dvdMenuCaptureJob struct { - mu sync.Mutex - cleanupOnce sync.Once - sessionID string - id string - sourcePath string - uploadOptions api.UploadOptions - core api.Core - logger interface{ Close() error } - overrides api.ExternalIDOverrides - nameOverrides api.ReleaseNameOverrides - status string - phase string - message string - discovered int - visitedStates int - visitedButtons int - captured int - warnings int - result api.DVDMenuCaptureResult - errorMessage string - startedAt time.Time - finishedAt time.Time - lastEmit time.Time - cancel context.CancelFunc +type webDVDRunner struct { + core acceptedDVDCapture + target PreparedGenerationTransfer + seed preparedrelease.Seed + expected api.ReleaseRef } -// StartDVDMenuCapture starts a session-owned background DVD menu capture job -// using cached prepared metadata. The returned job continues after the request -// context ends and emits dvdmenu:job: only to the owning session. -func (b *Backend) StartDVDMenuCapture(ctx context.Context, sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) (string, error) { - rt, err := b.requireRuntime() +func (r webDVDRunner) CaptureAcceptedDVDMenus(ctx context.Context, input api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + ref, err := r.target.ImportReleaseSeed(ctx, r.seed) if err != nil { - return "", err + return api.DVDMenuCaptureResult{}, fmt.Errorf("import canonical generation: %w", err) } - if ctx == nil { - return "", errors.New("request context is required") + if ref != r.expected { + return api.DVDMenuCaptureResult{}, errors.New("import canonical generation: unexpected release reference") } - trimmedPath := strings.TrimSpace(path) - if trimmedPath == "" { - return "", errors.New("path is required") + result, err := r.core.CaptureAcceptedDVDMenus(ctx, input) + if err != nil { + return api.DVDMenuCaptureResult{}, fmt.Errorf("web: capture accepted DVD menus: %w", err) } - req := newWebDVDMenuRequest(trimmedPath, overrides, nameOverrides, rt.baseUploadOptions()) + return result, nil +} - var preparedMeta api.PreparedMetadata - preparedMetaFound := false - if exporter, ok := rt.core.(guishared.PreparedMetaExporter); ok { - preparedMeta, preparedMetaFound, err = exporter.ExportGUICachedPreparedMeta(ctx, req) - if err != nil { - return "", fmt.Errorf("DVD menu capture metadata preview cache: %w", err) - } - if !preparedMetaFound { - return "", errors.New(errDVDMenuCaptureRequiresMetadataCache) - } +// CaptureDVDMenus executes one exact-generation media operation. Request +// cancellation reaches Core and the operation is not retained as a Job. +func (b *Backend) CaptureDVDMenus( + ctx context.Context, + release api.ReleaseRef, +) (api.DVDMenuCaptureResult, error) { + rt, err := b.requireRuntime() + if err != nil { + return api.DVDMenuCaptureResult{}, err } - if err := ctx.Err(); err != nil { - return "", fmt.Errorf("DVD menu capture metadata preview cache: %w", err) + if !rt.capabilities.PreparedGenerationReady() { + return api.DVDMenuCaptureResult{}, ErrPreparedGenerationUnavailable } - //nolint:contextcheck // Per-run core construction has no context-aware API; request context is checked before and after setup. - runCore, runLogger, err := b.buildRunCoreFromSnapshot(rt, runOptions{}) - if err != nil { - return "", err + if ctx == nil { + return api.DVDMenuCaptureResult{}, errors.New("request context is required") } - if err := ctx.Err(); err != nil { - _ = runCore.Close() - _ = runLogger.Close() - return "", fmt.Errorf("DVD menu capture metadata preview cache: %w", err) + expectedRef, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return api.DVDMenuCaptureResult{}, err } - if preparedMetaFound { - importer, ok := runCore.(guishared.PreparedMetaImporter) - if !ok { - _ = runCore.Close() - _ = runLogger.Close() - return "", errors.New("DVD menu capture metadata preview cache: run core cannot import prepared metadata") - } - if err := importer.ImportPreparedMetadataForGUI(ctx, req, preparedMeta); err != nil { - _ = runCore.Close() - _ = runLogger.Close() - return "", fmt.Errorf("DVD menu capture metadata preview cache: %w", err) - } + seed, err := rt.capabilities.PreparedGenerationTransfer.ExportReleaseSeed(ctx, expectedRef) + if err != nil { + return api.DVDMenuCaptureResult{}, fmt.Errorf("DVD menu capture exact generation: %w", err) } - - job := &dvdMenuCaptureJob{ - sessionID: strings.TrimSpace(sessionID), - id: randomJobID(), - sourcePath: trimmedPath, - uploadOptions: req.Options, - core: runCore, - logger: runLogger, - overrides: overrides, - nameOverrides: nameOverrides, - status: "queued", - phase: "queued", - message: "DVD menu capture queued.", - startedAt: time.Now().UTC(), + if err := ctx.Err(); err != nil { + return api.DVDMenuCaptureResult{}, fmt.Errorf("DVD menu capture canceled before setup: %w", err) } - jobCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - job.cancel = cancel - b.dvdMenuMu.Lock() - if b.dvdMenus == nil { - b.dvdMenus = make(map[string]*dvdMenuCaptureJob) + runCapabilities, runOwner, runLogger, err := b.buildRunCoreFromSnapshot(ctx, rt, runOptions{}) + if err != nil { + return api.DVDMenuCaptureResult{}, err } - b.dvdMenus[job.id] = job - b.pruneCompletedDVDMenuJobsLocked(maxCompletedDVDMenuJobs) - b.dvdMenuWG.Add(1) - b.dvdMenuMu.Unlock() - b.emitDVDMenuCaptureSnapshot(job, true) - go func() { - defer cancel() - b.runDVDMenuCaptureJob(jobCtx, job) - }() - return job.id, nil -} - -// GetDVDMenuCaptureSnapshot returns a capture job only to its owning session. -// Unknown, expired, and foreign-session job IDs return the same not-found error. -func (b *Backend) GetDVDMenuCaptureSnapshot(sessionID string, jobID string) (api.DVDMenuCaptureSnapshot, error) { - job := b.getDVDMenuCaptureJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return api.DVDMenuCaptureSnapshot{}, errors.New("DVD menu capture job not found") + defer closeJobResources(runOwner, runLogger) + if err := ctx.Err(); err != nil { + return api.DVDMenuCaptureResult{}, fmt.Errorf("DVD menu capture canceled after setup: %w", err) } - return buildWebDVDMenuCaptureSnapshot(job), nil -} - -// CancelDVDMenuCapture requests cancellation only for an owned capture job. -// Completion is asynchronous and is reflected in later snapshots. -func (b *Backend) CancelDVDMenuCapture(sessionID string, jobID string) error { - job := b.getDVDMenuCaptureJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return errors.New("DVD menu capture job not found") + if !runCapabilities.PreparedDVDReady() { + return api.DVDMenuCaptureResult{}, ErrPreparedDVDUnavailable } - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() + result, err := (webDVDRunner{ + core: runCapabilities.DVD, + target: runCapabilities.PreparedGenerationTransfer, + seed: seed, + expected: expectedRef, + }).CaptureAcceptedDVDMenus(ctx, api.MediaPlanInput{Release: expectedRef}) + if err != nil { + return api.DVDMenuCaptureResult{}, err } - return nil + return result, nil } -// ListDVDMenuScreenshots returns persisted manual and automatic menu images for -// one host filesystem source path. The call requires prepared GUI metadata. -func (b *Backend) ListDVDMenuScreenshots(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides) ([]api.ScreenshotImage, error) { +// ListDVDMenuScreenshots returns persisted manual and automatic menu images for a prepared DVD. +func (b *Backend) ListDVDMenuScreenshots( + release api.ReleaseRef, +) ([]api.ScreenshotImage, error) { rt, err := b.requireRuntime() if err != nil { return nil, err } + dvdMenuCore, err := rt.dvdMenuCore() + if err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := newWebDVDMenuRequest(path, overrides, nameOverrides, rt.baseUploadOptions()) - return wrapWebResult(rt.core.ListDVDMenuScreenshots(ctx, req)) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) + if err != nil { + return nil, err + } + return wrapWebResult(dvdMenuCore.ListAcceptedDVDMenuScreenshots(ctx, api.MediaPlanInput{Release: ref})) } -// DeleteDVDMenuScreenshot removes one persisted image from the prepared -// release's managed directory and local records. Remote-host assets remain. -func (b *Backend) DeleteDVDMenuScreenshot(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, imagePath string) error { +// DeleteDVDMenuScreenshot removes one managed DVD menu image and its prepared-release reference. +func (b *Backend) DeleteDVDMenuScreenshot(release api.ReleaseRef, imagePath string) error { rt, err := b.requireRuntime() if err != nil { return err } + dvdMenuCore, err := rt.dvdMenuCore() + if err != nil { + return err + } ctx, cancel := context.WithTimeout(context.Background(), previewTimeout) defer cancel() - req := newWebDVDMenuRequest(path, overrides, nameOverrides, rt.baseUploadOptions()) - return wrapWebError(rt.core.DeleteDVDMenuScreenshot(ctx, req, imagePath)) -} - -func newWebDVDMenuRequest(path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, options api.UploadOptions) api.Request { - return api.Request{ - Paths: []string{strings.TrimSpace(path)}, - Mode: api.ModeGUI, - Options: options, - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - } -} - -// runDVDMenuCaptureJob owns the queued-to-terminal lifecycle, releases its -// per-run resources, and schedules completed-job retention cleanup. -func (b *Backend) runDVDMenuCaptureJob(ctx context.Context, job *dvdMenuCaptureJob) { - defer b.dvdMenuWG.Done() - defer job.closeResources() - if job == nil || job.core == nil { - return - } - job.mu.Lock() - job.status = "running" - job.phase = "preflight" - job.message = "Checking DVD menu capture support." - job.mu.Unlock() - b.emitDVDMenuCaptureSnapshot(job, true) - - progressCtx := api.WithDVDMenuProgressReporter(ctx, func(update api.DVDMenuProgressUpdate) { - b.applyDVDMenuCaptureProgress(job, update) - }) - req := newWebDVDMenuRequest(job.sourcePath, job.overrides, job.nameOverrides, job.uploadOptions) - result, err := job.core.CaptureDVDMenus(progressCtx, req) - - job.mu.Lock() - job.result = result - job.finishedAt = time.Now().UTC() - job.cancel = nil - job.discovered = result.DiscoveredMenus - job.visitedStates = result.VisitedStates - job.visitedButtons = result.VisitedButtons - job.captured = len(result.Images) - job.warnings = len(result.Warnings) + ref, err := normalizeExactRelease(release, api.OperationKindMedia) if err != nil { - if ctx.Err() != nil { - job.status = "canceled" - job.phase = "canceled" - job.message = "DVD menu capture canceled." - job.errorMessage = "DVD menu capture canceled" - } else { - job.status = "failed" - job.phase = "failed" - job.message = "DVD menu capture failed." - job.errorMessage = redaction.RedactValue(err.Error(), nil) - } - } else { - job.status = "completed" - job.phase = "complete" - job.message = "DVD menu capture finished." - job.errorMessage = "" - } - job.mu.Unlock() - b.emitDVDMenuCaptureSnapshot(job, true) - b.scheduleDVDMenuJobCleanup(job) -} - -func (b *Backend) applyDVDMenuCaptureProgress(job *dvdMenuCaptureJob, update api.DVDMenuProgressUpdate) { - job.mu.Lock() - if strings.TrimSpace(update.Phase) != "" { - job.phase = strings.TrimSpace(update.Phase) - } - if strings.TrimSpace(update.Message) != "" { - job.message = strings.TrimSpace(update.Message) - } - job.discovered = update.DiscoveredMenus - job.visitedStates = update.VisitedStates - job.visitedButtons = update.VisitedButtons - job.captured = update.CapturedCount - job.warnings = update.WarningCount - job.mu.Unlock() - b.emitDVDMenuCaptureSnapshot(job, false) -} - -// emitDVDMenuCaptureSnapshot publishes a throttled session event; force -// bypasses throttling for queued and terminal transitions. -func (b *Backend) emitDVDMenuCaptureSnapshot(job *dvdMenuCaptureJob, force bool) { - if b == nil || b.hub == nil || job == nil { - return - } - job.mu.Lock() - if !force && time.Since(job.lastEmit) < dvdMenuSnapshotThrottle { - job.mu.Unlock() - return - } - job.lastEmit = time.Now() - job.mu.Unlock() - b.hub.Emit(job.sessionID, "dvdmenu:job:"+job.id, buildWebDVDMenuCaptureSnapshot(job)) -} - -func buildWebDVDMenuCaptureSnapshot(job *dvdMenuCaptureJob) api.DVDMenuCaptureSnapshot { - job.mu.Lock() - defer job.mu.Unlock() - return api.DVDMenuCaptureSnapshot{ - JobID: job.id, - SourcePath: job.sourcePath, - Status: job.status, - Phase: job.phase, - Message: job.message, - DiscoveredMenus: job.discovered, - VisitedStates: job.visitedStates, - VisitedButtons: job.visitedButtons, - CapturedCount: job.captured, - WarningCount: job.warnings, - Result: job.result, - Error: job.errorMessage, - StartedAt: formatTime(job.startedAt), - FinishedAt: formatTime(job.finishedAt), - } -} - -func (b *Backend) getDVDMenuCaptureJob(jobID string) *dvdMenuCaptureJob { - if strings.TrimSpace(jobID) == "" { - return nil - } - b.dvdMenuMu.Lock() - defer b.dvdMenuMu.Unlock() - return b.dvdMenus[jobID] -} - -func (b *Backend) getDVDMenuCaptureJobForSession(sessionID string, jobID string) *dvdMenuCaptureJob { - job := b.getDVDMenuCaptureJob(jobID) - if job == nil { - return nil - } - job.mu.Lock() - defer job.mu.Unlock() - if strings.TrimSpace(job.sessionID) != sessionID { - return nil - } - return job -} - -// closeResources closes the per-run core and logger at most once. -func (job *dvdMenuCaptureJob) closeResources() { - if job == nil { - return - } - job.cleanupOnce.Do(func() { - job.mu.Lock() - coreSvc := job.core - logger := job.logger - job.core = nil - job.logger = nil - job.mu.Unlock() - if coreSvc != nil { - _ = coreSvc.Close() - } - if logger != nil { - _ = logger.Close() - } - }) -} - -func (b *Backend) scheduleDVDMenuJobCleanup(job *dvdMenuCaptureJob) { - b.dvdMenuMu.Lock() - b.pruneCompletedDVDMenuJobsLocked(maxCompletedDVDMenuJobs) - b.dvdMenuMu.Unlock() - time.AfterFunc(completedJobRetention, func() { - b.dvdMenuMu.Lock() - defer b.dvdMenuMu.Unlock() - if current := b.dvdMenus[job.id]; current == job && isDVDMenuJobTerminal(job) { - delete(b.dvdMenus, job.id) - } - }) -} - -func (b *Backend) pruneCompletedDVDMenuJobsLocked(maxCompleted int) { - if maxCompleted <= 0 { - return - } - completed := make([]*dvdMenuCaptureJob, 0, len(b.dvdMenus)) - for _, job := range b.dvdMenus { - if isDVDMenuJobTerminal(job) { - completed = append(completed, job) - } - } - if len(completed) <= maxCompleted { - return - } - sort.Slice(completed, func(i, j int) bool { - return completed[i].finishedAt.Before(completed[j].finishedAt) - }) - for _, job := range completed[:len(completed)-maxCompleted] { - delete(b.dvdMenus, job.id) - } -} - -func isDVDMenuJobTerminal(job *dvdMenuCaptureJob) bool { - if job == nil { - return false - } - job.mu.Lock() - defer job.mu.Unlock() - return job.status == "completed" || job.status == "failed" || job.status == "canceled" -} - -func (b *Backend) stopAllDVDMenuJobs() { - if b == nil { - return - } - b.dvdMenuMu.Lock() - jobs := make([]*dvdMenuCaptureJob, 0, len(b.dvdMenus)) - for _, job := range b.dvdMenus { - jobs = append(jobs, job) - } - b.dvdMenuMu.Unlock() - for _, job := range jobs { - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() - } - } - b.dvdMenuWG.Wait() - for _, job := range jobs { - job.closeResources() + return err } + return wrapWebError(dvdMenuCore.DeleteAcceptedDVDMenuScreenshot(ctx, api.MediaPlanInput{Release: ref}, imagePath)) } diff --git a/internal/webserver/dvd_menu_jobs_test.go b/internal/webserver/dvd_menu_jobs_test.go index 8f321905..d9a4d010 100644 --- a/internal/webserver/dvd_menu_jobs_test.go +++ b/internal/webserver/dvd_menu_jobs_test.go @@ -6,113 +6,42 @@ package webserver import ( "context" "errors" - "strings" "testing" - "time" + "github.com/autobrr/upbrr/internal/preparedrelease" "github.com/autobrr/upbrr/pkg/api" ) -func TestRunDVDMenuCaptureJobPublishesProgressAndResult(t *testing.T) { - t.Parallel() +type acceptedDVDCaptureFunc func(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) - coreSvc := &preparedMetaTestCore{ - dvdMenuCapture: func(ctx context.Context, _ api.Request) (api.DVDMenuCaptureResult, error) { - api.ReportDVDMenuProgress(ctx, api.DVDMenuProgressUpdate{ - Phase: "capturing", - DiscoveredMenus: 2, - VisitedStates: 4, - VisitedButtons: 3, - CapturedCount: 1, - }) - return api.DVDMenuCaptureResult{ - SourcePath: "Example.Release.2026.1080p-GRP", - DiscoveredMenus: 2, - VisitedStates: 4, - VisitedButtons: 3, - Complete: true, - Images: []api.DVDMenuCaptureImage{{ScreenshotImage: api.ScreenshotImage{ - Path: "menu-1.png", - Purpose: api.ScreenshotPurposeMenu, - }}}, - }, nil - }, - } - job := &dvdMenuCaptureJob{ - sessionID: "session-a", - id: "dvd-job-1", - core: coreSvc, - status: "queued", - startedAt: time.Now().UTC(), - } - backend := &Backend{dvdMenus: map[string]*dvdMenuCaptureJob{job.id: job}} - backend.dvdMenuWG.Add(1) - - backend.runDVDMenuCaptureJob(context.Background(), job) - snapshot := buildWebDVDMenuCaptureSnapshot(job) - if snapshot.Status != "completed" || snapshot.CapturedCount != 1 || !snapshot.Result.Complete { - t.Fatalf("unexpected terminal snapshot: %#v", snapshot) - } - if coreSvc.dvdMenuCalls != 1 { - t.Fatalf("expected one capture call, got %d", coreSvc.dvdMenuCalls) - } - if coreSvc.closeCalls != 1 { - t.Fatalf("expected one core close, got %d", coreSvc.closeCalls) - } +func (fn acceptedDVDCaptureFunc) CaptureAcceptedDVDMenus(ctx context.Context, input api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + return fn(ctx, input) } -func TestRunDVDMenuCaptureJobRedactsFailureMessage(t *testing.T) { - t.Parallel() +type dvdGenerationTransfer struct{ release api.ReleaseRef } - coreSvc := &preparedMetaTestCore{ - dvdMenuCapture: func(context.Context, api.Request) (api.DVDMenuCaptureResult, error) { - return api.DVDMenuCaptureResult{}, errors.New("provider rejected api_key=secret-value") - }, - } - job := &dvdMenuCaptureJob{ - sessionID: "session-a", - id: "dvd-job-1", - core: coreSvc, - status: "queued", - startedAt: time.Now().UTC(), - } - backend := &Backend{dvdMenus: map[string]*dvdMenuCaptureJob{job.id: job}} - backend.dvdMenuWG.Add(1) +func (transfer dvdGenerationTransfer) ExportReleaseSeed(context.Context, api.ReleaseRef) (preparedrelease.Seed, error) { + return preparedrelease.Seed{}, nil +} - backend.runDVDMenuCaptureJob(context.Background(), job) - snapshot := buildWebDVDMenuCaptureSnapshot(job) - if snapshot.Status != "failed" { - t.Fatal("expected failed snapshot") - } - if strings.Contains(snapshot.Error, "secret-value") || !strings.Contains(snapshot.Error, "[REDACTED]") { - t.Fatal("expected redacted DVD menu capture error") - } +func (transfer dvdGenerationTransfer) ImportReleaseSeed(context.Context, preparedrelease.Seed) (api.ReleaseRef, error) { + return transfer.release, nil } -func TestDVDMenuCaptureJobAccessRequiresOwningSession(t *testing.T) { +func TestWebDVDMenuRunnerPropagatesRequestCancellation(t *testing.T) { t.Parallel() - - job := &dvdMenuCaptureJob{ - sessionID: "session-a", - id: "dvd-job-1", - status: "running", - startedAt: time.Now().UTC(), - } - backend := &Backend{dvdMenus: map[string]*dvdMenuCaptureJob{job.id: job}} - - if _, err := backend.GetDVDMenuCaptureSnapshot("session-b", job.id); err == nil || err.Error() != "DVD menu capture job not found" { - t.Fatal("expected foreign session snapshot read to use the not-found privacy response") - } - if err := backend.CancelDVDMenuCapture("session-b", job.id); err == nil || err.Error() != "DVD menu capture job not found" { - t.Fatal("expected foreign session cancellation to use the not-found privacy response") - } - if _, err := backend.GetDVDMenuCaptureSnapshot("session-b", "missing-job"); err == nil || err.Error() != "DVD menu capture job not found" { - t.Fatal("expected unknown snapshot read to use the same not-found privacy response") - } - if err := backend.CancelDVDMenuCapture("session-b", "missing-job"); err == nil || err.Error() != "DVD menu capture job not found" { - t.Fatal("expected unknown cancellation to use the same not-found privacy response") - } - if _, err := backend.GetDVDMenuCaptureSnapshot("session-a", job.id); err != nil { - t.Fatalf("expected owning session snapshot read: %v", err) + release := api.ReleaseRef{SourcePath: `C:\Example\Example.Release.2026.1080p-GRP`, Generation: 1} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + runner := webDVDRunner{ + core: acceptedDVDCaptureFunc(func(ctx context.Context, _ api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, ctx.Err() + }), + target: dvdGenerationTransfer{release: release}, + expected: release, + } + _, err := runner.CaptureAcceptedDVDMenus(ctx, api.MediaPlanInput{Release: release}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("capture error = %v", err) } } diff --git a/internal/webserver/events.go b/internal/webserver/events.go index bb6b593f..0ab0c06f 100644 --- a/internal/webserver/events.go +++ b/internal/webserver/events.go @@ -9,11 +9,14 @@ import ( "time" ) +// serverEvent is one encoded server-sent event awaiting session delivery. type serverEvent struct { Name string Data []byte } +// eventHub routes non-blocking, session-scoped events to active subscribers. +// Slow subscribers lose events instead of blocking producers. type eventHub struct { mu sync.Mutex subscribers map[string]map[chan serverEvent]struct{} @@ -37,6 +40,8 @@ func (h *eventHub) SetLogger(logger interface { h.mu.Unlock() } +// Subscribe creates a buffered event stream for sessionID. The returned cleanup +// removes the subscriber and closes its channel, and must be called exactly once. func (h *eventHub) Subscribe(sessionID string) (<-chan serverEvent, func()) { ch := make(chan serverEvent, 64) @@ -60,6 +65,8 @@ func (h *eventHub) Subscribe(sessionID string) (<-chan serverEvent, func()) { } } +// Emit JSON-encodes payload and attempts delivery to every subscriber for +// sessionID. Invalid payloads and full subscriber buffers are dropped. func (h *eventHub) Emit(sessionID string, name string, payload any) { if h == nil || sessionID == "" || name == "" { return @@ -84,6 +91,7 @@ func (h *eventHub) Emit(sessionID string, name string, payload any) { } } +// EmitKeepAlive sends a UTC RFC3339 timestamp on the session's keepalive event. func (h *eventHub) EmitKeepAlive(sessionID string) { h.Emit(sessionID, "keepalive", map[string]string{ "timestamp": time.Now().UTC().Format(time.RFC3339), diff --git a/internal/webserver/events_test.go b/internal/webserver/events_test.go index aed9273c..544736ce 100644 --- a/internal/webserver/events_test.go +++ b/internal/webserver/events_test.go @@ -29,7 +29,7 @@ func TestEventHubLogsDroppedEvents(t *testing.T) { hub.subscribers[sessionID] = map[chan serverEvent]struct{}{ch: {}} hub.mu.Unlock() - hub.Emit(sessionID, "metadata:progress", map[string]string{"status": "running"}) + hub.Emit(sessionID, "preparation:progress", map[string]string{"status": "running"}) if len(logger.messages) != 1 { t.Fatalf("expected one dropped-event log message, got %d", len(logger.messages)) diff --git a/internal/webserver/image_upload_progress_test.go b/internal/webserver/image_upload_progress_test.go new file mode 100644 index 00000000..3a6ccd8f --- /dev/null +++ b/internal/webserver/image_upload_progress_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestImageUploadProgressBoundaryCorrelatesAndSanitizesEvents(t *testing.T) { + hub := newEventHub() + backend := &Backend{hub: hub} + events, unsubscribe := hub.Subscribe("session-1") + t.Cleanup(unsubscribe) + + ctx, err := backend.withImageUploadProgress(context.Background(), "session-1", "image-upload-1") + if err != nil { + t.Fatalf("create image upload progress context: %v", err) + } + api.EmitImageUploadProgress(ctx, api.ImageUploadProgressUpdate{ + AttemptID: "imgbox|global", + Host: "imgbox", + UsageScope: "global", + Trackers: []string{"AITHER"}, + Completed: 1, + Total: 4, + Succeeded: 1, + Status: api.ImageUploadProgressRunning, + Message: `source=C:\media\Example.Release.2026.1080p-GRP api_key=synthetic-secret`, + }) + + select { + case event := <-events: + if event.Name != "image-upload:progress" { + t.Fatalf("event name=%q", event.Name) + } + var update api.ImageUploadProgressUpdate + if err := json.Unmarshal(event.Data, &update); err != nil { + t.Fatalf("decode image upload progress: %v", err) + } + if update.CorrelationID != "image-upload-1" || update.Timestamp == "" { + t.Fatal("image upload event is missing correlation or timestamp") + } + if strings.Contains(update.Message, "synthetic-secret") || strings.Contains(update.Message, `C:\media`) { + t.Fatal("image upload event exposed sensitive or local-path detail") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for image upload event") + } +} + +func TestImageUploadProgressBoundaryRejectsEmptyCorrelation(t *testing.T) { + backend := &Backend{hub: newEventHub()} + if _, err := backend.withImageUploadProgress(context.Background(), "session-1", " "); err == nil { + t.Fatal("empty image upload correlation ID was accepted") + } +} diff --git a/internal/webserver/jobs.go b/internal/webserver/jobs.go index ea07d9b9..9362ae7f 100644 --- a/internal/webserver/jobs.go +++ b/internal/webserver/jobs.go @@ -5,174 +5,192 @@ package webserver import ( "context" - "crypto/rand" "errors" "fmt" "maps" - "math/big" - "sort" - "strconv" "strings" - "sync" "time" - "github.com/autobrr/upbrr/internal/guishared" "github.com/autobrr/upbrr/internal/logging" + "github.com/autobrr/upbrr/internal/preparedrelease" + "github.com/autobrr/upbrr/internal/webserver/jobs" "github.com/autobrr/upbrr/pkg/api" ) const ( - completedJobRetention = 24 * time.Hour - maxCompletedDupeJobs = 200 - maxCompletedUploadJobs = 200 - seedPreparedMetaTimeout = 30 * time.Second - trackerUploadProgressEvent = "upload:progress" + seedPreparedMetaTimeout = 30 * time.Second + trackerUploadSnapshotThrottle = 200 * time.Millisecond + trackerUploadHashRateEmitDeltaMiB = 1.0 + trackerUploadProgressEvent = "upload:progress" + errDupeCheckRequiresMetadataPreview = "dupe check requires metadata preview" ) -const errDupeCheckRequiresMetadataPreview = "dupe check requires metadata preview" +type preparedUploadRunner interface { + RunAcceptedUpload(context.Context, api.UploadExecutionPlan) (api.Result, error) +} -// DupeCheckTrackerState reports frontend-visible state for one tracker in a -// dupe check job. -type DupeCheckTrackerState struct { - Tracker string `json:"tracker"` - Status string `json:"status"` - Message string `json:"message"` - Result api.DupeCheckResult `json:"result"` - StartedAt string `json:"startedAt"` - FinishedAt string `json:"finishedAt"` +type dupeCheckRunner interface { + CheckAcceptedDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) } -// DupeCheckSnapshot reports frontend-visible state for a dupe check job. -type DupeCheckSnapshot struct { - JobID string `json:"jobID"` - SourcePath string `json:"sourcePath"` - Status string `json:"status"` - Trackers []DupeCheckTrackerState `json:"trackers"` - CompletedCount int `json:"completedCount"` - TotalCount int `json:"totalCount"` - Summary api.DupeCheckSummary `json:"summary"` - Error string `json:"error"` - StartedAt string `json:"startedAt"` - FinishedAt string `json:"finishedAt"` +// DupeCheckTrackerState aliases the shared frontend-visible state for one duplicate-check tracker. +type DupeCheckTrackerState = jobs.DupeCheckTrackerState + +// DupeCheckSnapshot aliases the shared frontend-visible duplicate-check job snapshot. +type DupeCheckSnapshot = jobs.DupeCheckSnapshot + +// TrackerUploadTrackerState aliases the shared frontend-visible state for one tracker upload. +type TrackerUploadTrackerState = jobs.TrackerUploadTrackerState + +// TrackerUploadSnapshot aliases the shared frontend-visible tracker-upload job snapshot. +type TrackerUploadSnapshot = jobs.TrackerUploadSnapshot + +// webJobSink routes shared snapshots to the authenticated owner's event stream. +type webJobSink struct { + hub *eventHub + logger api.Logger +} + +// EmitDupe emits a duplicate-check snapshot only to the owning session. +func (s webJobSink) EmitDupe(ownerID string, snapshot jobs.DupeCheckSnapshot) { + if s.hub != nil { + s.hub.Emit(ownerID, "dupe:job:"+snapshot.JobID, snapshot) + s.hub.Emit(ownerID, "jobs:update", jobs.OwnerJobSnapshot{ + Kind: jobs.KindDuplicateCheck, + JobID: snapshot.JobID, + CorrelationID: snapshot.CorrelationID, + Release: snapshot.Release, + Status: snapshot.Status, + StartedAt: snapshot.StartedAt, + FinishedAt: snapshot.FinishedAt, + Dupe: &snapshot, + }) + } } -type dupeCheckJob struct { - mu sync.Mutex - cleanupOnce sync.Once - sessionID string - id string - sourcePath string - uploadOptions api.UploadOptions - core api.Core - logger interface{ Close() error } - overrides api.ExternalIDOverrides - nameOverrides api.ReleaseNameOverrides - trackers []string - states map[string]DupeCheckTrackerState - completedCount int - totalCount int - summary api.DupeCheckSummary - status string - errorMessage string - startedAt time.Time - finishedAt time.Time - cancel context.CancelFunc +// EmitUpload emits a tracker-upload snapshot only to the owning session. +func (s webJobSink) EmitUpload(ownerID string, snapshot jobs.TrackerUploadSnapshot) { + if s.hub != nil { + s.hub.Emit(ownerID, "upload:job:"+snapshot.JobID, snapshot) + s.hub.Emit(ownerID, "jobs:update", jobs.OwnerJobSnapshot{ + Kind: jobs.KindTrackerUpload, + JobID: snapshot.JobID, + CorrelationID: snapshot.CorrelationID, + RetryOf: snapshot.RetryOf, + Release: snapshot.Release, + Status: snapshot.Status, + StartedAt: snapshot.StartedAt, + FinishedAt: snapshot.FinishedAt, + Upload: &snapshot, + }) + } +} + +// WarnJob records a sanitized lifecycle warning through the event hub logger. +func (s webJobSink) WarnJob(message string) { + if s.logger != nil { + s.logger.Warnf("jobs: %s", message) + } +} + +func (b *Backend) ensureJobOwner(sessionID string) (*jobs.OwnerHandle, error) { + if b == nil { + return nil, jobs.ErrEngineClosed + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, errors.New("session id is required") + } + b.jobOwnerMu.Lock() + defer b.jobOwnerMu.Unlock() + if b.jobEngine == nil { + b.jobEngine = jobs.New(webJobSink{hub: b.hub, logger: b.currentLogger()}, jobs.Config{}) + } + if b.jobOwners == nil { + b.jobOwners = make(map[string]*jobs.OwnerHandle) + } + if owner := b.jobOwners[sessionID]; owner != nil { + return owner, nil + } + owner, err := b.jobEngine.RegisterOwner(sessionID) + if err != nil { + return nil, fmt.Errorf("register job owner: %w", err) + } + b.jobOwners[sessionID] = owner + return owner, nil } -// TrackerUploadTrackerState reports frontend-visible state for one tracker in -// an upload job. UploadedCount includes accepted uploads returned before a later -// tracker error or cancellation. -type TrackerUploadTrackerState struct { - Tracker string `json:"tracker"` - Status string `json:"status"` - Task string `json:"task"` - TaskStatus string `json:"taskStatus"` - Message string `json:"message"` - CompletedPieces int `json:"completedPieces"` - TotalPieces int `json:"totalPieces"` - Percent int `json:"percent"` - HashRateMiB float64 `json:"hashRateMiB"` - UploadedCount int `json:"uploadedCount"` - StartedAt string `json:"startedAt"` - FinishedAt string `json:"finishedAt"` +func (b *Backend) lookupJobOwner(sessionID string) *jobs.OwnerHandle { + if b == nil { + return nil + } + b.jobOwnerMu.Lock() + defer b.jobOwnerMu.Unlock() + return b.jobOwners[strings.TrimSpace(sessionID)] } -// TrackerUploadSnapshot reports frontend-visible state for a tracker upload -// job. UploadedCount is the sum of per-tracker accepted uploads, including -// partial counts returned with non-nil errors. -type TrackerUploadSnapshot struct { - JobID string `json:"jobID"` - SourcePath string `json:"sourcePath"` - Status string `json:"status"` - CurrentTask string `json:"currentTask"` - CurrentTaskStatus string `json:"currentTaskStatus"` - CurrentMessage string `json:"currentMessage"` - CurrentCompletedPieces int `json:"currentCompletedPieces"` - CurrentTotalPieces int `json:"currentTotalPieces"` - CurrentPercent int `json:"currentPercent"` - CurrentHashRateMiB float64 `json:"currentHashRateMiB"` - Trackers []TrackerUploadTrackerState `json:"trackers"` - FailedTrackers []string `json:"failedTrackers"` - UploadedCount int `json:"uploadedCount"` - Error string `json:"error"` - StartedAt string `json:"startedAt"` - FinishedAt string `json:"finishedAt"` +func (b *Backend) removeJobOwner(sessionID string) { + if b == nil || b.jobEngine == nil { + return + } + sessionID = strings.TrimSpace(sessionID) + b.jobOwnerMu.Lock() + owner := b.jobOwners[sessionID] + delete(b.jobOwners, sessionID) + b.jobOwnerMu.Unlock() + if owner != nil { + _ = b.jobEngine.RemoveOwner(owner) + } + if b.reviews != nil { + b.reviews.PurgeOwner(sessionID) + } } -type trackerUploadJob struct { - mu sync.Mutex - cleanupOnce sync.Once - sessionID string - id string - sourcePath string - // uploadOptions is the per-job runtime snapshot needed for upload requests; - // the full config may contain secrets and must not be retained in job state. - uploadOptions api.UploadOptions - runOptions runOptions - core api.Core - logger interface{ Close() error } - overrides api.ExternalIDOverrides - nameOverrides api.ReleaseNameOverrides - questionnaireAnswers map[string]map[string]string - descriptionGroups []api.DescriptionBuilderGroup - trackers []string - ignoreDupesFor []string - states map[string]TrackerUploadTrackerState - failedTrackers []string - uploadedCount int - status string - currentTask string - currentTaskStatus string - currentMessage string - currentCompleted int - currentTotal int - currentPercent int - currentHashRateMiB float64 - lastSnapshotEmit time.Time - snapshotThrottle time.Duration - errorMessage string - startedAt time.Time - finishedAt time.Time - cancel context.CancelFunc +// webUploadRunner preserves the embedded-web error prefix around prepared upload execution. +type webUploadRunner struct { + core preparedUploadRunner + generationTarget PreparedGenerationTransfer + generationSeed preparedrelease.Seed + expectedRelease api.ReleaseRef } -type trackerUploadRetryRequest struct { - sessionID string - sourcePath string - overrides api.ExternalIDOverrides - nameOverrides api.ReleaseNameOverrides - questionnaireAnswers map[string]map[string]string - descriptionGroups []api.DescriptionBuilderGroup - failedTrackers []string - ignoreDupesFor []string - runOptions runOptions - uploadOptions api.UploadOptions +// RunUpload delegates prepared upload execution and applies the embedded-web error contract. +func (r webUploadRunner) RunUpload(ctx context.Context, plan api.UploadExecutionPlan) (api.Result, error) { + ref, err := r.generationTarget.ImportReleaseSeed(ctx, r.generationSeed) + if err != nil { + return api.Result{}, fmt.Errorf("web: import canonical generation: %w", err) + } + if ref != r.expectedRelease { + return api.Result{}, errors.New("web: import canonical generation: unexpected release reference") + } + return wrapWebResult(r.core.RunAcceptedUpload(ctx, plan)) } -const trackerUploadSnapshotThrottle = 200 * time.Millisecond +// webDupeRunner preserves the direct duplicate-check error contract used by both transports. +type webDupeRunner struct { + core dupeCheckRunner + generationTarget PreparedGenerationTransfer + generationSeed preparedrelease.Seed + expectedRelease api.ReleaseRef +} -const trackerUploadHashRateEmitDeltaMiB = 1.0 +// CheckDupes delegates duplicate checking without changing runner error text. +func (r webDupeRunner) CheckDupes(ctx context.Context, input api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + ref, err := r.generationTarget.ImportReleaseSeed(ctx, r.generationSeed) + if err != nil { + return api.DupeCheckSummary{}, fmt.Errorf("import canonical generation: %w", err) + } + if ref != r.expectedRelease { + return api.DupeCheckSummary{}, errors.New("import canonical generation: unexpected release reference") + } + summary, err := r.core.CheckAcceptedDupes(ctx, input) + if err != nil { + return api.DupeCheckSummary{}, fmt.Errorf("%w", err) + } + return summary, nil +} +// normalizeTrackerList trims, removes blanks, and deduplicates tracker names case-insensitively. func normalizeTrackerList(trackers []string) []string { seen := make(map[string]struct{}) resolved := make([]string, 0, len(trackers)) @@ -191,6 +209,7 @@ func normalizeTrackerList(trackers []string) []string { return resolved } +// cloneQuestionnaireAnswers deep-copies tracker questionnaire maps for request isolation. func cloneQuestionnaireAnswers(input map[string]map[string]string) map[string]map[string]string { if len(input) == 0 { return nil @@ -204,6 +223,7 @@ func cloneQuestionnaireAnswers(input map[string]map[string]string) map[string]ma return cloned } +// normalizePatterns trims, removes blanks, and deduplicates log exclusion patterns. func normalizePatterns(patterns []string) []string { seen := make(map[string]struct{}) result := make([]string, 0, len(patterns)) @@ -221,976 +241,418 @@ func normalizePatterns(patterns []string) []string { return result } -func randomJobID() string { - value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(^uint64(0))) +// StartDupeCheck starts a session-owned duplicate-check job for a host filesystem path. +// Request cancellation applies only to preview-cache preparation; accepted jobs continue until completion or explicit cancellation. +func (b *Backend) StartDupeCheck( + ctx context.Context, + sessionID string, + release api.ReleaseRef, + trackers []string, + correlationID string, +) (string, error) { + owner, err := b.ensureJobOwner(sessionID) if err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 10) + return "", err } - return fmt.Sprintf("%d-%x", time.Now().UnixNano(), value.Uint64()) -} - -// StartDupeCheck starts a dupe check job owned by sessionID and returns its job -// ID. The request context controls the pre-job metadata cache export/import -// work; after the job is accepted, cancellation is detached and callers must use -// CancelDupeCheck. When the active core exposes the GUI prepared-metadata cache, -// the matching preview must exist before a durable job is created. -func (b *Backend) StartDupeCheck(ctx context.Context, sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackers []string) (string, error) { rt, err := b.requireRuntime() if err != nil { return "", err } + if !rt.capabilities.PreparedGenerationReady() { + return "", ErrPreparedGenerationUnavailable + } if ctx == nil { return "", errors.New("request context is required") } - trimmedPath := strings.TrimSpace(path) - if trimmedPath == "" { - return "", errors.New("path is required") + release.SourcePath = strings.TrimSpace(release.SourcePath) + if release.SourcePath == "" || release.Generation == 0 { + return "", api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureInvalidSource, + Operation: api.OperationKindDuplicateCheck, + Message: "Source path is required.", + Recovery: api.OperationRecoveryEditInput, + }, errors.New("path is required")) } resolvedTrackers := normalizeTrackerList(trackers) if len(resolvedTrackers) == 0 { - return "", errors.New("at least one tracker must be selected") + return "", api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureNoEligibleTrackers, + Operation: api.OperationKindDuplicateCheck, + Message: "No trackers are eligible for duplicate checking.", + Recovery: api.OperationRecoverySelectTrackers, + }, errors.New("at least one tracker must be selected")) } - req := api.Request{ - Paths: []string{trimmedPath}, - Mode: api.ModeGUI, - Trackers: resolvedTrackers, - Options: rt.baseUploadOptions(), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - } - var preparedMeta api.PreparedMetadata - preparedMetaFound := false - if exporter, ok := rt.core.(guishared.PreparedMetaExporter); ok { - var found bool - preparedMeta, found, err = exporter.ExportGUICachedPreparedMeta(ctx, req) - if err != nil { - return "", fmt.Errorf("dupe check metadata preview cache: %w", err) - } - if !found { - return "", errors.New(errDupeCheckRequiresMetadataPreview) - } - preparedMetaFound = true + generationSeed, err := rt.capabilities.PreparedGenerationTransfer.ExportReleaseSeed(ctx, release) + if err != nil { + return "", fmt.Errorf("dupe check exact generation: %w", err) } - if err := ctx.Err(); err != nil { - return "", fmt.Errorf("dupe check metadata preview cache: %w", err) + return "", fmt.Errorf("dupe check canceled before run setup: %w", err) } - //nolint:contextcheck // Per-run core construction has no context-aware API; request context is checked before and after setup. - runCore, runLogger, err := b.buildRunCoreFromSnapshot(rt, runOptions{}) + + runCapabilities, runOwner, runLogger, err := b.buildRunCoreFromSnapshot(ctx, rt, runOptions{}) if err != nil { return "", err } if err := ctx.Err(); err != nil { - _ = runCore.Close() - _ = runLogger.Close() + closeJobResources(runOwner, runLogger) return "", fmt.Errorf("dupe check metadata preview cache: %w", err) } - if preparedMetaFound { - importer, ok := runCore.(guishared.PreparedMetaImporter) - if !ok { - _ = runCore.Close() - _ = runLogger.Close() - return "", errors.New("dupe check metadata preview cache: run core cannot import prepared metadata") - } - if err := importer.ImportPreparedMetadataForGUI(ctx, req, preparedMeta); err != nil { - _ = runCore.Close() - _ = runLogger.Close() - return "", fmt.Errorf("dupe check metadata preview cache: %w", err) - } - } - - jobID := randomJobID() - states := make(map[string]DupeCheckTrackerState, len(resolvedTrackers)) - for _, tracker := range resolvedTrackers { - normalized := strings.ToUpper(strings.TrimSpace(tracker)) - states[normalized] = DupeCheckTrackerState{Tracker: normalized, Status: "queued", Message: "queued"} - } - job := &dupeCheckJob{ - sessionID: sessionID, - id: jobID, - sourcePath: trimmedPath, - uploadOptions: req.Options, - core: runCore, - logger: runLogger, - overrides: overrides, - nameOverrides: nameOverrides, - trackers: resolvedTrackers, - states: states, - totalCount: len(states), - summary: api.DupeCheckSummary{SourcePath: trimmedPath}, - status: "queued", - startedAt: time.Now().UTC(), + if !runCapabilities.PreparedDupeReady() || !runCapabilities.PreparedGenerationReady() { + closeJobResources(runOwner, runLogger) + return "", ErrPreparedDupeUnavailable + } + jobID, err := b.jobEngine.StartDupe(context.WithoutCancel(ctx), owner, jobs.DupeSpec{ + CorrelationID: correlationID, + Snapshot: jobs.DuplicateExecutionSnapshot{ + Seed: generationSeed, + PreparedGeneration: release.Generation, + RuntimeGeneration: rt.generationID, + Input: api.DuplicateCheckInput{ + Release: release, + Trackers: append([]string(nil), resolvedTrackers...), + Interaction: api.InteractionModeInteractive, + }, + }, + Runner: webDupeRunner{ + core: runCapabilities.DuplicateExecution, + generationTarget: runCapabilities.PreparedGenerationTransfer, + generationSeed: generationSeed, + expectedRelease: release, + }, + Resources: jobs.Resources{Core: runOwner, Logger: runLogger}, + }) + if err != nil { + closeJobResources(runOwner, runLogger) + return "", fmt.Errorf("web: start dupe job: %w", err) } - - jobCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - job.cancel = cancel - - b.dupeMu.Lock() - b.dupes[jobID] = job - b.pruneCompletedDupeJobsLocked(maxCompletedDupeJobs) - b.dupeMu.Unlock() - b.emitDupeCheckSnapshot(job) - - b.dupeWG.Add(1) - go func() { - defer cancel() - b.runDupeCheckJob(jobCtx, job) - }() return jobID, nil } -// GetDupeCheckSnapshot returns a dupe job snapshot only to the session that -// started the job. +// GetDupeCheckSnapshot returns an immutable job snapshot when sessionID owns jobID. +// Unknown and foreign-owned IDs return the same not-found error. func (b *Backend) GetDupeCheckSnapshot(sessionID string, jobID string) (DupeCheckSnapshot, error) { - job := b.getDupeCheckJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return DupeCheckSnapshot{}, errors.New("dupe job not found") + snapshot, err := b.jobEngine.DupeSnapshot(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)) + if err != nil { + return DupeCheckSnapshot{}, fmt.Errorf("%w", err) } - return buildDupeCheckSnapshot(job), nil + return snapshot, nil } -// CancelDupeCheck requests cancellation only for a dupe job owned by sessionID. +// CancelDupeCheck requests cancellation when sessionID owns jobID. +// Unknown and foreign-owned IDs return the same not-found error. func (b *Backend) CancelDupeCheck(sessionID string, jobID string) error { - job := b.getDupeCheckJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return errors.New("dupe job not found") - } - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() + if err := b.jobEngine.CancelDupe(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)); err != nil { + return fmt.Errorf("%w", err) } return nil } -func (b *Backend) runDupeCheckJob(ctx context.Context, job *dupeCheckJob) { - defer b.dupeWG.Done() - defer func() { - job.closeResources() - }() - - job.mu.Lock() - job.status = "running" - job.mu.Unlock() - b.emitDupeCheckSnapshot(job) - - progressCtx := api.WithDupeProgressReporter(ctx, func(update api.DupeProgressUpdate) { - b.applyDupeProgress(job, update) - }) - - req := api.Request{ - Paths: []string{job.sourcePath}, - Mode: api.ModeGUI, - Trackers: job.trackers, - Options: job.uploadOptions, - ExternalIDOverrides: job.overrides, - ReleaseNameOverrides: job.nameOverrides, - } - - if job.core == nil { - err := errors.New("core not initialized") - job.mu.Lock() - job.finishedAt = time.Now().UTC() - job.cancel = nil - job.status = "failed" - job.errorMessage = logging.SanitizeMessage(err.Error()) - job.mu.Unlock() - b.emitDupeCheckSnapshot(job) - b.scheduleDupeJobCleanup(job) - return - } - - summary, err := job.core.CheckDupes(progressCtx, req) - job.mu.Lock() - job.finishedAt = time.Now().UTC() - job.cancel = nil +// ReviewTrackerUpload stores a session-owned exact execution snapshot and +// returns the single-use opaque token required to start it. +func (b *Backend) ReviewTrackerUpload( + ctx context.Context, + sessionID string, + release api.ReleaseRef, + trackers []string, + ignoreDupesFor []string, + ruleAuthorizations []api.RuleAuthorization, + questionnaireAnswers map[string]map[string]string, + descriptionGroups []api.DescriptionBuilderGroup, + noSeed bool, + runLogLevel string, +) (api.UploadReviewResult, error) { + runOpts, err := b.buildRunOptions(noSeed, runLogLevel) if err != nil { - if ctx.Err() != nil { - job.status = "canceled" - job.errorMessage = "dupe check canceled" - } else { - job.status = "failed" - job.errorMessage = logging.SanitizeMessage(err.Error()) - } - job.mu.Unlock() - b.emitDupeCheckSnapshot(job) - b.scheduleDupeJobCleanup(job) - return + return api.UploadReviewResult{}, err } - job.summary = summary - for _, result := range summary.Results { - for _, tracker := range dupeResultTrackerNames(result) { - state := job.states[tracker] - if !isDupeTerminalStatus(state.Status) { - job.completedCount++ - } - state.Tracker = tracker - state.Status = resultStatus(result) - state.Message = resultMessage(result) - state.Result = result - state.Result.Tracker = tracker - if state.StartedAt == "" { - state.StartedAt = job.startedAt.Format(time.RFC3339) - } - state.FinishedAt = job.finishedAt.Format(time.RFC3339) - job.states[tracker] = state - } - } - if hasFailedDupeState(job.states) { - job.status = "completed_with_errors" - if strings.TrimSpace(job.errorMessage) == "" { - job.errorMessage = "one or more tracker dupe checks failed" - } - } else { - job.status = "completed" - job.errorMessage = "" + if _, err := b.ensureJobOwner(sessionID); err != nil { + return api.UploadReviewResult{}, err } - job.mu.Unlock() - b.emitDupeCheckSnapshot(job) - b.scheduleDupeJobCleanup(job) -} - -// applyDupeProgress merges one tracker update and emits the resulting job -// snapshot. Trackers discovered after job creation increase the total once only -// when the producer omitted an explicit total. -func (b *Backend) applyDupeProgress(job *dupeCheckJob, update api.DupeProgressUpdate) { - tracker := strings.ToUpper(strings.TrimSpace(update.Tracker)) - if tracker == "" { - return - } - job.mu.Lock() - state := job.states[tracker] - if state.Tracker == "" { - state.Tracker = tracker - job.trackers = append(job.trackers, tracker) - if update.Total <= 0 { - job.totalCount++ - } + rt, err := b.requireRuntime() + if err != nil { + return api.UploadReviewResult{}, err } - previousStatus := state.Status - if strings.TrimSpace(update.Status) != "" { - state.Status = strings.TrimSpace(update.Status) + if !rt.capabilities.PreparedGenerationReady() || !CapabilityAvailable(rt.capabilities.UploadReview) { + return api.UploadReviewResult{}, ErrPreparedGenerationUnavailable } - if strings.TrimSpace(update.Message) != "" { - state.Message = strings.TrimSpace(update.Message) + reviewCore, err := rt.uploadReviewCore() + if err != nil { + return api.UploadReviewResult{}, err } - if state.Status == "running" && state.StartedAt == "" { - state.StartedAt = time.Now().UTC().Format(time.RFC3339) + release.SourcePath = strings.TrimSpace(release.SourcePath) + if release.SourcePath == "" || release.Generation == 0 { + return api.UploadReviewResult{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureInvalidSource, + Operation: api.OperationKindUploadReview, + Message: "Source path is required.", + Recovery: api.OperationRecoveryEditInput, + }, errors.New("path is required")) } - if update.Result.Tracker != "" { - state.Result = update.Result - upsertDupeSummaryResult(&job.summary, update.Result) + resolvedTrackers := normalizeTrackerList(trackers) + if len(resolvedTrackers) == 0 { + return api.UploadReviewResult{}, api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureNoEligibleTrackers, + Operation: api.OperationKindUploadReview, + Message: "No trackers are eligible for upload.", + Recovery: api.OperationRecoverySelectTrackers, + }, errors.New("at least one tracker must be selected")) + } + ignore := normalizeTrackerList(ignoreDupesFor) + options := buildRunUploadOptions(rt.cfg, runOpts) + if ctx == nil { + return api.UploadReviewResult{}, errors.New("request context is required") } - if isDupeTerminalStatus(state.Status) && !isDupeTerminalStatus(previousStatus) { - job.completedCount++ - state.FinishedAt = time.Now().UTC().Format(time.RFC3339) + generationSeed, err := rt.capabilities.PreparedGenerationTransfer.ExportReleaseSeed(ctx, release) + if err != nil { + return api.UploadReviewResult{}, fmt.Errorf("web: exact upload generation: %w", err) + } + reviewInput := api.UploadReviewInput{ + Release: release, + Trackers: append([]string(nil), resolvedTrackers...), + IgnoreDupesFor: append([]string(nil), ignore...), + RuleAuthorizations: cloneWebRuleAuthorizations(ruleAuthorizations), + QuestionnaireAnswers: cloneQuestionnaireAnswers(questionnaireAnswers), + DescriptionGroups: api.CloneDescriptionBuilderGroups(descriptionGroups), + Options: options, } - if update.Total > job.totalCount { - job.totalCount = update.Total + reviewed, err := reviewCore.ReviewAcceptedUpload(ctx, reviewInput) + if err != nil { + return api.UploadReviewResult{}, fmt.Errorf("web: %w", err) + } + token, err := b.uploadReviewRegistry().Issue(sessionID, UploadReviewSnapshot{ + Execution: jobs.UploadExecutionSnapshot{ + Seed: generationSeed, + PreparedGeneration: release.Generation, + RuntimeGeneration: rt.generationID, + Input: reviewInput, + Outcome: reviewed.Outcome, + }, + Review: reviewed.Review, + }) + if err != nil { + return api.UploadReviewResult{}, fmt.Errorf("web: issue upload review token: %w", err) } - job.states[tracker] = state - job.mu.Unlock() - b.emitDupeCheckSnapshot(job) + return api.UploadReviewResult{Review: reviewed.Review, Token: token}, nil } -func (j *trackerUploadJob) closeResources() { - if j == nil { - return +func cloneWebRuleAuthorizations(values []api.RuleAuthorization) []api.RuleAuthorization { + if len(values) == 0 { + return nil } - j.cleanupOnce.Do(func() { - j.mu.Lock() - coreSvc := j.core - logger := j.logger - j.core = nil - j.logger = nil - j.mu.Unlock() - if coreSvc != nil { - _ = coreSvc.Close() - } - if logger != nil { - _ = logger.Close() - } - }) -} - -func (j *dupeCheckJob) closeResources() { - if j == nil { - return + cloned := make([]api.RuleAuthorization, len(values)) + for idx, value := range values { + cloned[idx] = api.RuleAuthorization{Tracker: value.Tracker, Rules: append([]string(nil), value.Rules...)} } - j.cleanupOnce.Do(func() { - j.mu.Lock() - coreSvc := j.core - logger := j.logger - j.core = nil - j.logger = nil - j.mu.Unlock() - if coreSvc != nil { - _ = coreSvc.Close() - } - if logger != nil { - _ = logger.Close() - } - }) + return cloned } -func trackerUploadRetryRequestFromJob(job *trackerUploadJob) (trackerUploadRetryRequest, error) { - if job == nil { - return trackerUploadRetryRequest{}, errors.New("upload job not found") +// StartReviewedTrackerUpload consumes one session-owned reviewed snapshot and +// starts it only while the reviewed runtime generation remains active. +func (b *Backend) StartReviewedTrackerUpload(sessionID string, token string, correlationID string) (string, error) { + owner, err := b.ensureJobOwner(sessionID) + if err != nil { + return "", err } - - job.mu.Lock() - defer job.mu.Unlock() - - if len(job.failedTrackers) == 0 { - return trackerUploadRetryRequest{}, errors.New("no failed trackers to retry") + reviewed, err := b.uploadReviewRegistry().Consume(sessionID, token) + if err != nil { + return "", api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureMissingReview, + Operation: api.OperationKindUploadExecute, + Message: "Upload review is missing or expired.", + Recovery: api.OperationRecoveryReviewAgain, + }, err) } - - return trackerUploadRetryRequest{ - sessionID: job.sessionID, - sourcePath: job.sourcePath, - overrides: job.overrides, - nameOverrides: job.nameOverrides, - questionnaireAnswers: cloneQuestionnaireAnswers(job.questionnaireAnswers), - descriptionGroups: api.CloneDescriptionBuilderGroups(job.descriptionGroups), - failedTrackers: append([]string(nil), job.failedTrackers...), - ignoreDupesFor: append([]string(nil), job.ignoreDupesFor...), - runOptions: job.runOptions, - uploadOptions: job.uploadOptions, - }, nil -} - -// StartTrackerUpload starts an upload job owned by sessionID for selected -// trackers and returns its job ID. Snapshots preserve partial upload counts -// returned with later tracker errors or cancellation. The job captures upload -// options at start time so failed-tracker retries reuse the original option set. -func (b *Backend) StartTrackerUpload(sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackers []string, ignoreDupesFor []string, questionnaireAnswers map[string]map[string]string, descriptionGroups []api.DescriptionBuilderGroup, debug bool, noSeed bool, runLogLevel string) (string, error) { - return b.startTrackerUpload(sessionID, path, overrides, nameOverrides, trackers, ignoreDupesFor, questionnaireAnswers, descriptionGroups, debug, noSeed, runLogLevel, nil) -} - -func (b *Backend) startTrackerUpload(sessionID string, path string, overrides api.ExternalIDOverrides, nameOverrides api.ReleaseNameOverrides, trackers []string, ignoreDupesFor []string, questionnaireAnswers map[string]map[string]string, descriptionGroups []api.DescriptionBuilderGroup, debug bool, noSeed bool, runLogLevel string, uploadOptions *api.UploadOptions) (string, error) { rt, err := b.requireRuntime() if err != nil { return "", err } - trimmedPath := strings.TrimSpace(path) - if trimmedPath == "" { - return "", errors.New("path is required") + if reviewed.Execution.RuntimeGeneration != rt.generationID { + return "", api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureStaleReview, + Operation: api.OperationKindUploadExecute, + Message: "Upload review is stale after configuration changed.", + Recovery: api.OperationRecoveryReviewAgain, + }, errors.New("runtime generation changed")) } - resolvedTrackers := normalizeTrackerList(trackers) - if len(resolvedTrackers) == 0 { - return "", errors.New("at least one tracker must be selected") + runOpts := runOptions{ + NoSeed: reviewed.Execution.Input.Options.NoSeed, + RunLogLevel: reviewed.Execution.Input.Options.RunLogLevel, } - runOpts, err := b.buildRunOptions(debug, noSeed, runLogLevel) + runCapabilities, runOwner, runLogger, err := b.buildRunCoreFromSnapshot(context.Background(), rt, runOpts) if err != nil { return "", err } - runCore, runLogger, err := b.buildRunCoreFromSnapshot(rt, runOpts) + if !runCapabilities.PreparedUploadReady() || !runCapabilities.PreparedGenerationReady() { + closeJobResources(runOwner, runLogger) + return "", ErrPreparedUploadUnavailable + } + input := reviewed.Execution.Input + spec := jobs.UploadSpec{ + CorrelationID: correlationID, + Snapshot: reviewed.Execution, + Runner: webUploadRunner{ + core: runCapabilities.UploadExecution, + generationTarget: runCapabilities.PreparedGenerationTransfer, + generationSeed: reviewed.Execution.Seed, + expectedRelease: input.Release, + }, + Resources: jobs.Resources{Core: runOwner, Logger: runLogger}, + } + jobID, err := b.jobEngine.StartUpload(context.Background(), owner, spec) if err != nil { - return "", err + closeJobResources(runOwner, runLogger) + return "", fmt.Errorf("web: start upload job: %w", err) } - seedReq := api.Request{ - Paths: []string{trimmedPath}, - Mode: api.ModeGUI, - DescriptionGroups: api.CloneDescriptionBuilderGroups(descriptionGroups), - Trackers: resolvedTrackers, - IgnoreDupesFor: normalizeTrackerList(ignoreDupesFor), - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - } - seedCtx, cancel := context.WithTimeout(context.Background(), seedPreparedMetaTimeout) - defer cancel() - if err := guishared.SeedRunCorePreparedMeta(seedCtx, rt.core, runCore, seedReq); err != nil { - _ = runCore.Close() - _ = runLogger.Close() - return "", fmt.Errorf("web: %w", err) - } - - jobID := randomJobID() - jobUploadOptions := buildRunUploadOptions(rt.cfg, runOpts) - if uploadOptions != nil { - jobUploadOptions = *uploadOptions - } - job := &trackerUploadJob{ - sessionID: sessionID, - id: jobID, - sourcePath: trimmedPath, - uploadOptions: jobUploadOptions, - runOptions: runOpts, - core: runCore, - logger: runLogger, - overrides: overrides, - nameOverrides: nameOverrides, - questionnaireAnswers: cloneQuestionnaireAnswers(questionnaireAnswers), - descriptionGroups: api.CloneDescriptionBuilderGroups(descriptionGroups), - trackers: resolvedTrackers, - ignoreDupesFor: normalizeTrackerList(ignoreDupesFor), - states: make(map[string]TrackerUploadTrackerState, len(resolvedTrackers)), - status: "queued", - startedAt: time.Now().UTC(), - snapshotThrottle: trackerUploadSnapshotThrottle, - } - for _, tracker := range resolvedTrackers { - job.states[tracker] = TrackerUploadTrackerState{Tracker: tracker, Status: "queued", Message: "queued"} - } - jobCtx, cancel := context.WithCancel(context.Background()) - job.cancel = cancel - - b.uploadMu.Lock() - b.uploads[jobID] = job - b.pruneCompletedUploadJobsLocked(maxCompletedUploadJobs) - b.uploadMu.Unlock() - b.emitTrackerUploadSnapshot(job) - - b.uploadWG.Add(1) - go func() { - defer cancel() - b.runTrackerUploadJob(jobCtx, job) - }() return jobID, nil } -// CancelTrackerUpload requests cancellation only for an upload job owned by -// sessionID. -func (b *Backend) CancelTrackerUpload(sessionID string, jobID string) error { - job := b.getTrackerUploadJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return errors.New("upload job not found") +func (b *Backend) uploadReviewRegistry() *UploadReviewRegistry { + b.reviewMu.Lock() + defer b.reviewMu.Unlock() + if b.reviews == nil { + b.reviews = NewUploadReviewRegistry() } - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() - } - return nil + return b.reviews } -// RetryFailedTrackerUpload starts a retry job for failed trackers only when -// sessionID owns the original upload job. The retry reuses the original job's -// run options, upload options, questionnaire answers, description groups, and -// ignore-dupe list instead of rebuilding them from current settings. -func (b *Backend) RetryFailedTrackerUpload(sessionID string, jobID string) (string, error) { - job := b.getTrackerUploadJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return "", errors.New("upload job not found") - } - retry, err := trackerUploadRetryRequestFromJob(job) +// retryTrackerUpload rebuilds per-run resources while preserving the exact +// reviewed operation and canonical preparation lineage. +func (b *Backend) retryTrackerUpload(sessionID string, spec jobs.UploadSpec, runOpts runOptions) (string, error) { + owner, err := b.ensureJobOwner(sessionID) if err != nil { return "", err } - return b.startTrackerUpload(retry.sessionID, retry.sourcePath, retry.overrides, retry.nameOverrides, retry.failedTrackers, retry.ignoreDupesFor, retry.questionnaireAnswers, retry.descriptionGroups, retry.runOptions.Debug, retry.runOptions.NoSeed, retry.runOptions.RunLogLevel, &retry.uploadOptions) -} - -// GetTrackerUploadSnapshot returns an upload job snapshot only to the session -// that started the job. -func (b *Backend) GetTrackerUploadSnapshot(sessionID string, jobID string) (TrackerUploadSnapshot, error) { - job := b.getTrackerUploadJobForSession(strings.TrimSpace(sessionID), strings.TrimSpace(jobID)) - if job == nil { - return TrackerUploadSnapshot{}, errors.New("upload job not found") - } - return buildTrackerUploadSnapshot(job), nil -} - -// runTrackerUploadJob records UploadedCount before error handling so partial -// successes returned with non-nil errors remain visible in snapshots. -func (b *Backend) runTrackerUploadJob(ctx context.Context, job *trackerUploadJob) { - defer b.uploadWG.Done() - - job.mu.Lock() - job.status = "running" - job.mu.Unlock() - b.emitTrackerUploadSnapshot(job) - - for _, tracker := range job.trackers { - if ctx.Err() != nil { - break - } - job.mu.Lock() - state := job.states[tracker] - state.Status = "running" - state.Message = "uploading" - state.StartedAt = time.Now().UTC().Format(time.RFC3339) - job.states[tracker] = state - job.mu.Unlock() - b.emitTrackerUploadSnapshot(job) - - progressCtx := api.WithUploadProgressReporter(ctx, func(update api.UploadProgressUpdate) { - b.applyTrackerUploadProgress(job, update) - }) - result, err := b.runSingleTrackerUpload(progressCtx, job, tracker) - if result.UploadedCount > 0 { - job.mu.Lock() - state = job.states[tracker] - state.UploadedCount += result.UploadedCount - job.states[tracker] = state - job.uploadedCount += result.UploadedCount - job.mu.Unlock() - } - if err != nil { - if ctx.Err() != nil { - break - } - job.mu.Lock() - message := logging.SanitizeMessage(err.Error()) - state = job.states[tracker] - state.Status = "failed" - state.Message = message - state.FinishedAt = time.Now().UTC().Format(time.RFC3339) - job.states[tracker] = state - job.failedTrackers = append(job.failedTrackers, tracker) - job.errorMessage = message - job.mu.Unlock() - b.emitTrackerUploadSnapshot(job) - continue - } - - job.mu.Lock() - state = job.states[tracker] - state.Status = "success" - state.Message = "uploaded" - state.FinishedAt = time.Now().UTC().Format(time.RFC3339) - job.states[tracker] = state - job.mu.Unlock() - b.emitTrackerUploadSnapshot(job) - } - - job.mu.Lock() - job.finishedAt = time.Now().UTC() - switch { - case ctx.Err() != nil: - job.status = "canceled" - job.errorMessage = "upload canceled" - for _, tracker := range job.trackers { - state := job.states[tracker] - if state.Status == "queued" || state.Status == "running" { - state.Status = "canceled" - state.Message = "canceled" - state.FinishedAt = job.finishedAt.Format(time.RFC3339) - job.states[tracker] = state - } - } - case len(job.failedTrackers) > 0: - job.status = "completed_with_errors" - default: - job.status = "completed" - job.errorMessage = "" - } - job.cancel = nil - job.mu.Unlock() - job.closeResources() - b.emitTrackerUploadSnapshot(job) - b.scheduleTrackerUploadJobCleanup(job) -} - -func (b *Backend) applyTrackerUploadProgress(job *trackerUploadJob, update api.UploadProgressUpdate) { - if b == nil || job == nil { - return - } - tracker := strings.TrimSpace(update.Tracker) - if tracker == "" && len(job.trackers) == 1 { - tracker = job.trackers[0] - } - - job.mu.Lock() - now := time.Now() - previousStatus := job.currentTaskStatus - previousPercent := job.currentPercent - previousHashRate := job.currentHashRateMiB - job.currentTask = strings.TrimSpace(update.Task) - job.currentTaskStatus = strings.TrimSpace(update.Status) - job.currentMessage = strings.TrimSpace(update.Message) - job.currentCompleted = update.CompletedPieces - job.currentTotal = update.TotalPieces - job.currentPercent = update.Percent - job.currentHashRateMiB = update.HashRateMiB - throttle := job.snapshotThrottle - if throttle <= 0 { - throttle = trackerUploadSnapshotThrottle - } - shouldEmit := job.lastSnapshotEmit.IsZero() || - now.Sub(job.lastSnapshotEmit) >= throttle || - previousPercent != job.currentPercent || - previousStatus != job.currentTaskStatus || - absFloat64(previousHashRate-job.currentHashRateMiB) >= trackerUploadHashRateEmitDeltaMiB - if tracker != "" { - state := job.states[tracker] - state.Tracker = tracker - state.Task = job.currentTask - state.TaskStatus = job.currentTaskStatus - state.CompletedPieces = update.CompletedPieces - state.TotalPieces = update.TotalPieces - state.Percent = update.Percent - state.HashRateMiB = update.HashRateMiB - if job.currentMessage != "" { - state.Message = job.currentMessage - } - if state.Status == "queued" && strings.EqualFold(job.currentTaskStatus, "running") { - state.Status = "running" - } - if state.StartedAt == "" && state.Status == "running" { - state.StartedAt = time.Now().UTC().Format(time.RFC3339) - } - job.states[tracker] = state - } - if shouldEmit { - job.lastSnapshotEmit = now + rt, err := b.requireRuntime() + if err != nil { + return "", err } - job.mu.Unlock() - - if shouldEmit { - b.emitTrackerUploadSnapshot(job) + if !rt.capabilities.PreparedGenerationReady() { + return "", ErrPreparedGenerationUnavailable } -} - -func absFloat64(value float64) float64 { - if value < 0 { - return -value + input := spec.Snapshot.Input + input.Release.SourcePath = strings.TrimSpace(input.Release.SourcePath) + if input.Release.SourcePath == "" { + return "", errors.New("path is required") } - return value -} - -func (b *Backend) runSingleTrackerUpload(ctx context.Context, job *trackerUploadJob, tracker string) (api.Result, error) { - req := api.Request{ - Paths: []string{job.sourcePath}, - Mode: api.ModeGUI, - DescriptionGroups: api.CloneDescriptionBuilderGroups(job.descriptionGroups), - Trackers: []string{tracker}, - IgnoreDupesFor: append([]string(nil), job.ignoreDupesFor...), - IgnoreTrackerRuleFailures: false, - Options: job.uploadOptions, - ExternalIDOverrides: job.overrides, - ReleaseNameOverrides: job.nameOverrides, - TrackerQuestionnaireAnswers: cloneQuestionnaireAnswers(job.questionnaireAnswers), - } - return wrapWebResult(job.core.RunUploadPrepared(ctx, req)) -} - -func upsertDupeSummaryResult(summary *api.DupeCheckSummary, result api.DupeCheckResult) { - if summary == nil { - return + input.Trackers = normalizeTrackerList(input.Trackers) + if len(input.Trackers) == 0 { + return "", errors.New("at least one tracker must be selected") } - tracker := strings.ToUpper(strings.TrimSpace(result.Tracker)) - for idx := range summary.Results { - if strings.ToUpper(strings.TrimSpace(summary.Results[idx].Tracker)) == tracker { - summary.Results[idx] = result - return - } + input.IgnoreDupesFor = normalizeTrackerList(input.IgnoreDupesFor) + spec.Snapshot.Input = input + if spec.Snapshot.RuntimeGeneration != rt.generationID { + return "", api.NewOperationError(api.OperationFailure{ + Code: api.OperationFailureStaleReview, + Operation: api.OperationKindUploadExecute, + Message: "Upload retry requires a new review after configuration changed.", + Recovery: api.OperationRecoveryReviewAgain, + }, errors.New("runtime generation changed")) } - summary.Results = append(summary.Results, result) -} -// dupeResultTrackerNames expands grouped dupe summary labels into the -// per-tracker state keys used by frontend snapshots. -func dupeResultTrackerNames(result api.DupeCheckResult) []string { - trackers := strings.Split(result.Tracker, ",") - names := make([]string, 0, len(trackers)) - seen := make(map[string]struct{}, len(trackers)) - for _, tracker := range trackers { - name := strings.ToUpper(strings.TrimSpace(tracker)) - if name == "" { - continue - } - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - names = append(names, name) + seedCtx, cancel := context.WithTimeout(context.Background(), seedPreparedMetaTimeout) + defer cancel() + releaseRef := spec.Snapshot.Input.Release + if _, err = rt.capabilities.PreparedGenerationTransfer.ExportReleaseSeed(seedCtx, releaseRef); err != nil { + return "", fmt.Errorf("web: canonical preparation: %w", err) } - return names -} - -func hasFailedDupeState(states map[string]DupeCheckTrackerState) bool { - for _, state := range states { - if strings.EqualFold(state.Status, "failed") { - return true - } + generationSeed := spec.Snapshot.Seed + runCapabilities, runOwner, runLogger, err := b.buildRunCoreFromSnapshot(seedCtx, rt, runOpts) + if err != nil { + return "", err } - return false -} - -func isDupeTerminalStatus(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "completed", "completed_with_errors", "skipped", "failed", "canceled": - return true - default: - return false + if !runCapabilities.PreparedUploadReady() || !runCapabilities.PreparedGenerationReady() { + closeJobResources(runOwner, runLogger) + return "", ErrPreparedUploadUnavailable } -} -func isDupeJobTerminal(job *dupeCheckJob) bool { - if job == nil { - return false + spec.Runner = webUploadRunner{ + core: runCapabilities.UploadExecution, + generationTarget: runCapabilities.PreparedGenerationTransfer, + generationSeed: generationSeed, + expectedRelease: releaseRef, } - job.mu.Lock() - defer job.mu.Unlock() - return isDupeTerminalStatus(job.status) && !job.finishedAt.IsZero() -} - -func isTrackerUploadJobTerminal(job *trackerUploadJob) bool { - if job == nil { - return false - } - job.mu.Lock() - defer job.mu.Unlock() - switch strings.ToLower(strings.TrimSpace(job.status)) { - case "completed", "completed_with_errors", "failed", "canceled": - return !job.finishedAt.IsZero() - default: - return false + spec.Resources = jobs.Resources{Core: runOwner, Logger: runLogger} + jobID, err := b.jobEngine.StartUpload(context.Background(), owner, spec) + if err != nil { + closeJobResources(runOwner, runLogger) + return "", fmt.Errorf("web: start upload job: %w", err) } + return jobID, nil } -func resultStatus(result api.DupeCheckResult) string { - if strings.TrimSpace(result.Status) != "" { - return result.Status - } - if result.Skipped { - return "skipped" - } - if strings.TrimSpace(result.Error) != "" { - return "failed" +// CancelTrackerUpload requests cancellation when sessionID owns jobID. +// Unknown and foreign-owned IDs return the same not-found error. +func (b *Backend) CancelTrackerUpload(sessionID string, jobID string) error { + if err := b.jobEngine.CancelUpload(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)); err != nil { + return fmt.Errorf("%w", err) } - return "completed" + return nil } -func resultMessage(result api.DupeCheckResult) string { - if strings.TrimSpace(result.Error) != "" { - return strings.TrimSpace(result.Error) - } - if strings.TrimSpace(result.SkipReason) != "" { - return strings.TrimSpace(result.SkipReason) - } - if result.HasDupes { - return fmt.Sprintf("%d dupes found", len(result.Filtered)) +// RetryFailedTrackerUpload starts a new session-owned job for only the original failed trackers. +// It rebuilds per-run resources from the current runtime while preserving the original job input and options. +func (b *Backend) RetryFailedTrackerUpload(sessionID string, jobID string, correlationID string) (string, error) { + retry, err := b.jobEngine.UploadRetry(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)) + if err != nil { + return "", fmt.Errorf("%w", err) } - return "no dupes found" -} - -func (b *Backend) emitDupeCheckSnapshot(job *dupeCheckJob) { - b.hub.Emit(job.sessionID, "dupe:job:"+job.id, buildDupeCheckSnapshot(job)) -} - -func buildDupeCheckSnapshot(job *dupeCheckJob) DupeCheckSnapshot { - job.mu.Lock() - defer job.mu.Unlock() - trackers := make([]DupeCheckTrackerState, 0, len(job.trackers)) - for _, tracker := range job.trackers { - normalized := strings.ToUpper(strings.TrimSpace(tracker)) - trackers = append(trackers, job.states[normalized]) - } - return DupeCheckSnapshot{ - JobID: job.id, - SourcePath: job.sourcePath, - Status: job.status, - Trackers: trackers, - CompletedCount: job.completedCount, - TotalCount: job.totalCount, - Summary: job.summary, - Error: job.errorMessage, - StartedAt: job.startedAt.Format(time.RFC3339), - FinishedAt: formatTime(job.finishedAt), + runOpts := runOptions{ + NoSeed: retry.Snapshot.Input.Options.NoSeed, + RunLogLevel: retry.Snapshot.Input.Options.RunLogLevel, } + return b.retryTrackerUpload(sessionID, retry.Spec(correlationID, nil, jobs.Resources{}), runOpts) } -func (b *Backend) getDupeCheckJob(jobID string) *dupeCheckJob { - b.dupeMu.Lock() - defer b.dupeMu.Unlock() - return b.dupes[jobID] -} - -func (b *Backend) getDupeCheckJobForSession(sessionID string, jobID string) *dupeCheckJob { - job := b.getDupeCheckJob(jobID) - if job == nil { - return nil +// ListJobs returns immutable retained duplicate-check and upload Jobs for one authenticated owner. +func (b *Backend) ListJobs(sessionID string) ([]jobs.OwnerJobSnapshot, error) { + owner, err := b.ensureJobOwner(sessionID) + if err != nil { + return nil, err } - job.mu.Lock() - defer job.mu.Unlock() - if strings.TrimSpace(job.sessionID) != sessionID { - return nil + snapshots, err := b.jobEngine.List(owner) + if err != nil { + return nil, fmt.Errorf("web: list owner jobs: %w", err) } - return job + return snapshots, nil } -func (b *Backend) scheduleDupeJobCleanup(job *dupeCheckJob) { - if job == nil { - return +// GetTrackerUploadSnapshot returns an immutable job snapshot when sessionID owns jobID. +// Unknown and foreign-owned IDs return the same not-found error. +func (b *Backend) GetTrackerUploadSnapshot(sessionID string, jobID string) (TrackerUploadSnapshot, error) { + snapshot, err := b.jobEngine.UploadSnapshot(b.lookupJobOwner(sessionID), strings.TrimSpace(jobID)) + if err != nil { + return TrackerUploadSnapshot{}, fmt.Errorf("%w", err) } - - b.dupeMu.Lock() - b.pruneCompletedDupeJobsLocked(maxCompletedDupeJobs) - b.dupeMu.Unlock() - - time.AfterFunc(completedJobRetention, func() { - b.dupeMu.Lock() - defer b.dupeMu.Unlock() - - if current := b.dupes[job.id]; current == job && isDupeJobTerminal(job) { - delete(b.dupes, job.id) - } - }) + return snapshot, nil } -func (b *Backend) pruneCompletedDupeJobsLocked(maxCompleted int) { - if maxCompleted <= 0 { - return - } - - completed := make([]*dupeCheckJob, 0, len(b.dupes)) - for _, job := range b.dupes { - if isDupeJobTerminal(job) { - completed = append(completed, job) - } +// closeJobResources releases adapter-owned resources after preparation or engine enrollment fails. +func closeJobResources(coreSvc jobs.Closer, logger *logging.Logger) { + if coreSvc != nil { + _ = coreSvc.Close() } - if len(completed) <= maxCompleted { - return - } - - sort.Slice(completed, func(i, j int) bool { - return completed[i].finishedAt.Before(completed[j].finishedAt) - }) - - for _, job := range completed[:len(completed)-maxCompleted] { - delete(b.dupes, job.id) + if logger != nil { + _ = logger.Close() } } +// stopAllDupeJobs remains as a compatibility shim for shutdown tests and closes the shared engine. func (b *Backend) stopAllDupeJobs() { - b.dupeMu.Lock() - jobs := make([]*dupeCheckJob, 0, len(b.dupes)) - for _, job := range b.dupes { - jobs = append(jobs, job) - } - b.dupeMu.Unlock() - for _, job := range jobs { - if job == nil { - continue - } - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() - } - } - b.dupeWG.Wait() -} - -func (b *Backend) emitTrackerUploadSnapshot(job *trackerUploadJob) { - b.hub.Emit(job.sessionID, "upload:job:"+job.id, buildTrackerUploadSnapshot(job)) -} - -func buildTrackerUploadSnapshot(job *trackerUploadJob) TrackerUploadSnapshot { - job.mu.Lock() - defer job.mu.Unlock() - trackers := make([]TrackerUploadTrackerState, 0, len(job.trackers)) - for _, tracker := range job.trackers { - trackers = append(trackers, job.states[tracker]) - } - return TrackerUploadSnapshot{ - JobID: job.id, - SourcePath: job.sourcePath, - Status: job.status, - CurrentTask: job.currentTask, - CurrentTaskStatus: job.currentTaskStatus, - CurrentMessage: job.currentMessage, - CurrentCompletedPieces: job.currentCompleted, - CurrentTotalPieces: job.currentTotal, - CurrentPercent: job.currentPercent, - CurrentHashRateMiB: job.currentHashRateMiB, - Trackers: trackers, - FailedTrackers: append([]string(nil), job.failedTrackers...), - UploadedCount: job.uploadedCount, - Error: job.errorMessage, - StartedAt: job.startedAt.Format(time.RFC3339), - FinishedAt: formatTime(job.finishedAt), - } -} - -func (b *Backend) getTrackerUploadJob(jobID string) *trackerUploadJob { - b.uploadMu.Lock() - defer b.uploadMu.Unlock() - return b.uploads[jobID] -} - -func (b *Backend) getTrackerUploadJobForSession(sessionID string, jobID string) *trackerUploadJob { - job := b.getTrackerUploadJob(jobID) - if job == nil { - return nil - } - job.mu.Lock() - defer job.mu.Unlock() - if strings.TrimSpace(job.sessionID) != sessionID { - return nil - } - return job -} - -func (b *Backend) scheduleTrackerUploadJobCleanup(job *trackerUploadJob) { - if job == nil { - return - } - - b.uploadMu.Lock() - b.pruneCompletedUploadJobsLocked(maxCompletedUploadJobs) - b.uploadMu.Unlock() - - time.AfterFunc(completedJobRetention, func() { - b.uploadMu.Lock() - defer b.uploadMu.Unlock() - - if current := b.uploads[job.id]; current == job && isTrackerUploadJobTerminal(job) { - delete(b.uploads, job.id) - } - }) -} - -func (b *Backend) pruneCompletedUploadJobsLocked(maxCompleted int) { - if maxCompleted <= 0 { - return - } - - completed := make([]*trackerUploadJob, 0, len(b.uploads)) - for _, job := range b.uploads { - if isTrackerUploadJobTerminal(job) { - completed = append(completed, job) - } - } - if len(completed) <= maxCompleted { - return - } - - sort.Slice(completed, func(i, j int) bool { - return completed[i].finishedAt.Before(completed[j].finishedAt) - }) - - for _, job := range completed[:len(completed)-maxCompleted] { - delete(b.uploads, job.id) + if b != nil && b.jobEngine != nil { + b.jobEngine.Close() } } +// stopAllUploadJobs remains as a compatibility shim for shutdown tests and closes the shared engine. func (b *Backend) stopAllUploadJobs() { - b.uploadMu.Lock() - jobs := make([]*trackerUploadJob, 0, len(b.uploads)) - for _, job := range b.uploads { - jobs = append(jobs, job) - } - b.uploadMu.Unlock() - for _, job := range jobs { - if job == nil { - continue - } - job.mu.Lock() - cancel := job.cancel - job.mu.Unlock() - if cancel != nil { - cancel() - } + if b != nil && b.jobEngine != nil { + b.jobEngine.Close() } - b.uploadWG.Wait() } +// stopAllLogStreams signals every active stream and waits for its worker to exit. func (b *Backend) stopAllLogStreams() { b.streamMu.Lock() streams := make([]*backendLogStream, 0, len(b.streams)) @@ -1205,17 +667,9 @@ func (b *Backend) stopAllLogStreams() { } b.streamMu.Unlock() for _, stream := range streams { - if stream == nil { - continue + if stream != nil { + <-stream.done } - <-stream.done } b.streamWG.Wait() } - -func formatTime(value time.Time) string { - if value.IsZero() { - return "" - } - return value.Format(time.RFC3339) -} diff --git a/internal/webserver/jobs/contract_test.go b/internal/webserver/jobs/contract_test.go new file mode 100644 index 00000000..36710936 --- /dev/null +++ b/internal/webserver/jobs/contract_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSnapshotJSONContractAndZeroTimes(t *testing.T) { + t.Parallel() + upload, err := json.Marshal(TrackerUploadSnapshot{ + JobID: "job", + Trackers: []TrackerUploadTrackerState{}, + FailedTrackers: []string{}, + }) + if err != nil { + t.Fatalf("marshal upload: %v", err) + } + for _, field := range []string{`"jobID":"job"`, `"release":{"SourcePath":"","Generation":0}`, `"status":""`, `"currentTask":""`, `"trackers":[]`, `"failedTrackers":[]`, `"startedAt":""`, `"finishedAt":""`} { + if !strings.Contains(string(upload), field) { + t.Errorf("upload JSON %s missing %s", upload, field) + } + } + dupe, err := json.Marshal(DupeCheckSnapshot{JobID: "job", Trackers: []DupeCheckTrackerState{}}) + if err != nil { + t.Fatalf("marshal dupe: %v", err) + } + for _, field := range []string{`"jobID":"job"`, `"release":{"SourcePath":"","Generation":0}`, `"status":""`, `"trackers":[]`, `"completedCount":0`, `"totalCount":0`, `"startedAt":""`, `"finishedAt":""`} { + if !strings.Contains(string(dupe), field) { + t.Errorf("dupe JSON %s missing %s", dupe, field) + } + } +} diff --git a/internal/webserver/jobs/dupe.go b/internal/webserver/jobs/dupe.go new file mode 100644 index 00000000..ff368648 --- /dev/null +++ b/internal/webserver/jobs/dupe.go @@ -0,0 +1,383 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +// dupeJob contains mutable state for one owner-scoped duplicate check. +type dupeJob struct { + mu sync.Mutex + id, correlationID string + release api.ReleaseRef + spec DupeSpec + trackers []string + states map[string]DupeCheckTrackerState + completedCount, totalCount int + explicitTotal bool + summary api.DupeCheckSummary + status string + failure, outcomeFailure *api.OperationFailure + outcomeStatus string + startedAt, finishedAt time.Time + outcomeAt time.Time +} + +func newDupeJob(id string, spec DupeSpec, now time.Time) *dupeJob { + trackers := spec.Snapshot.Input.Trackers + states := make(map[string]DupeCheckTrackerState, len(trackers)) + for _, tracker := range trackers { + states[tracker] = DupeCheckTrackerState{ + Tracker: tracker, + Status: StatusQueued, + Message: StatusQueued, + } + } + return &dupeJob{ + id: id, + correlationID: spec.CorrelationID, + release: spec.Snapshot.Input.Release, + spec: spec, + trackers: append([]string(nil), trackers...), + states: states, + totalCount: len(states), + summary: api.DupeCheckSummary{SourcePath: spec.Snapshot.Input.Release.SourcePath}, + status: StatusQueued, + startedAt: now, + } +} + +// runDupe executes the runner, merges progress and summary results, and closes resources before terminal emission. +func (e *Engine) runDupe(ctx context.Context, job *dupeJob) { + defer func() { + if recovered := recover(); recovered != nil { + e.failDupe(job, "dupe worker panicked: "+sanitizeMessage(fmt.Sprint(recovered))) + } + }() + job.mu.Lock() + job.status = StatusRunning + job.mu.Unlock() + e.emitDupeJob(job) + progressCtx := api.WithDupeProgressReporter(ctx, func(update api.DupeProgressUpdate) { e.applyDupeProgress(job, update) }) + summary, err := job.spec.Runner.CheckDupes(progressCtx, job.input()) + now := e.deps.now().UTC() + job.mu.Lock() + job.outcomeAt = now + job.summary = cloneSummary(summary) + for _, result := range summary.Results { + job.mergeResultLocked(result, now) + } + if err != nil { + if ctx.Err() != nil { + job.outcomeStatus = StatusCanceled + job.outcomeFailure = nil + job.cancelActiveLocked() + } else { + job.outcomeStatus = StatusFailed + job.outcomeFailure = failureForError( + err, + api.OperationKindDuplicateCheck, + "Duplicate check failed.", + api.OperationRecoveryRetry, + ) + job.failActiveLocked() + } + job.mu.Unlock() + return + } + if hasFailedDupeState(job.states) { + job.outcomeStatus = StatusCompletedWithErrors + job.outcomeFailure = genericFailure( + api.OperationKindDuplicateCheck, + "One or more tracker duplicate checks failed.", + api.OperationRecoveryRetry, + ) + } else { + job.outcomeStatus = StatusCompleted + job.outcomeFailure = nil + } + job.mu.Unlock() +} + +// failDupe converts a worker failure into a sanitized failed snapshot and terminates active tracker states. +func (e *Engine) failDupe(job *dupeJob, message string) { + e.warn("duplicate-check Job failed: " + sanitizeMessage(message)) + job.mu.Lock() + if job.outcomeAt.IsZero() { + job.outcomeAt = e.deps.now().UTC() + } + job.outcomeStatus = StatusFailed + job.outcomeFailure = genericFailure( + api.OperationKindDuplicateCheck, + "Duplicate check failed.", + api.OperationRecoveryRetry, + ) + job.failActiveLocked() + job.mu.Unlock() +} + +// finalizeDupe publishes the staged outcome only after per-Job resources close. +func (e *Engine) finalizeDupe(job *dupeJob) { + job.mu.Lock() + if job.outcomeStatus == "" { + job.outcomeStatus = StatusFailed + job.outcomeFailure = genericFailure( + api.OperationKindDuplicateCheck, + "Duplicate check failed.", + api.OperationRecoveryRetry, + ) + } + job.status = job.outcomeStatus + job.failure = cloneFailure(job.outcomeFailure) + job.finishedAt = e.deps.now().UTC() + job.mu.Unlock() +} + +// applyDupeProgress merges dynamically discovered trackers without double-counting terminal transitions. +func (e *Engine) applyDupeProgress(job *dupeJob, update api.DupeProgressUpdate) { + tracker := strings.ToUpper(strings.TrimSpace(update.Tracker)) + if tracker == "" { + return + } + now := e.deps.now().UTC() + job.mu.Lock() + state, exists := job.states[tracker] + if update.Total > 0 { + job.explicitTotal = true + } + if !exists { + state.Tracker = tracker + job.trackers = append(job.trackers, tracker) + if !job.explicitTotal { + job.totalCount++ + } + } + previousStatus := state.Status + if status := strings.TrimSpace(update.Status); status != "" { + state.Status = status + } + if message := strings.TrimSpace(update.Message); message != "" { + state.Message = sanitizeMessage(message) + } + if state.Status == StatusRunning && state.StartedAt == "" { + state.StartedAt = formatTime(now) + } + if strings.TrimSpace(update.Result.Tracker) != "" { + result := cloneResult(update.Result) + result.Tracker = tracker + state.Result = result + upsertDupeSummaryResult(&job.summary, result) + } + if isDupeTerminal(state.Status) { + if state.FinishedAt == "" { + state.FinishedAt = formatTime(now) + } + if !isDupeTerminal(previousStatus) { + job.completedCount++ + } + } + if update.Total > job.totalCount { + job.totalCount = update.Total + } + job.states[tracker] = state + job.mu.Unlock() + e.emitDupeJob(job) +} + +// mergeResultLocked upserts one canonical tracker result. +// The caller must hold j.mu. +func (j *dupeJob) mergeResultLocked(raw api.DupeCheckResult, now time.Time) { + tracker := strings.ToUpper(strings.TrimSpace(raw.Tracker)) + if tracker == "" { + return + } + result := cloneResult(raw) + result.Tracker = tracker + state, exists := j.states[tracker] + if !exists { + state.Tracker = tracker + j.trackers = append(j.trackers, tracker) + if !j.explicitTotal { + j.totalCount++ + } + } + if !isDupeTerminal(state.Status) { + j.completedCount++ + } + state.Status = resultStatus(result) + state.Message = sanitizeMessage(resultMessage(result)) + state.Result = result + if state.StartedAt == "" { + state.StartedAt = formatTime(j.startedAt) + } + state.FinishedAt = formatTime(now) + j.states[tracker] = state +} + +func (j *dupeJob) input() api.DuplicateCheckInput { + input := cloneDuplicateExecutionSnapshot(j.spec.Snapshot).Input + input.Trackers = append([]string(nil), j.trackers...) + return input +} + +// snapshot copies all mutable duplicate-check state for lock-free sink delivery. +func (j *dupeJob) snapshot() DupeCheckSnapshot { + j.mu.Lock() + defer j.mu.Unlock() + trackers := make([]DupeCheckTrackerState, 0, len(j.trackers)) + seen := make(map[string]struct{}, len(j.trackers)) + for _, tracker := range j.trackers { + normalized := strings.ToUpper(strings.TrimSpace(tracker)) + if normalized == "" { + continue + } + trackers = append(trackers, cloneDupeState(j.states[normalized])) + seen[normalized] = struct{}{} + } + for tracker, state := range j.states { + if _, ok := seen[tracker]; !ok { + trackers = append(trackers, cloneDupeState(state)) + } + } + return DupeCheckSnapshot{ + JobID: j.id, + CorrelationID: j.correlationID, + Release: j.release, + RuntimeGeneration: j.spec.Snapshot.RuntimeGeneration, + RequestedTrackers: append([]string(nil), j.spec.Snapshot.Input.Trackers...), + Status: j.status, + Trackers: trackers, + CompletedCount: j.completedCount, + TotalCount: j.totalCount, + Summary: cloneSummary(j.summary), + Failure: cloneFailure(j.failure), + StartedAt: formatTime(j.startedAt), + FinishedAt: formatTime(j.finishedAt), + } +} + +func (j *dupeJob) cancelActiveLocked() { + for tracker, state := range j.states { + if !isDupeTerminal(state.Status) { + j.completedCount++ + state.Status = StatusCanceled + state.Message = StatusCanceled + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + } + } +} +func (j *dupeJob) failActiveLocked() { + message := "Duplicate check failed." + if j.outcomeFailure != nil && strings.TrimSpace(j.outcomeFailure.Message) != "" { + message = j.outcomeFailure.Message + } + for tracker, state := range j.states { + if !isDupeTerminal(state.Status) { + j.completedCount++ + state.Status = StatusFailed + state.Message = message + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + } + } +} + +func upsertDupeSummaryResult(summary *api.DupeCheckSummary, result api.DupeCheckResult) { + tracker := strings.ToUpper(strings.TrimSpace(result.Tracker)) + if tracker == "" { + return + } + for idx := range summary.Results { + if strings.ToUpper(strings.TrimSpace(summary.Results[idx].Tracker)) == tracker { + summary.Results[idx] = cloneResult(result) + return + } + } + summary.Results = append(summary.Results, cloneResult(result)) +} +func hasFailedDupeState(states map[string]DupeCheckTrackerState) bool { + for _, state := range states { + if strings.EqualFold(state.Status, StatusFailed) { + return true + } + } + return false +} +func isDupeTerminal(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusCompleted, "skipped", StatusFailed, StatusCanceled: + return true + default: + return false + } +} +func isJobTerminal(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusCompleted, StatusCompletedWithErrors, StatusFailed, StatusCanceled: + return true + default: + return false + } +} +func resultStatus(result api.DupeCheckResult) string { + if status := strings.TrimSpace(result.Status); status != "" { + return status + } + if result.Skipped { + return "skipped" + } + if strings.TrimSpace(result.Error) != "" { + return StatusFailed + } + return StatusCompleted +} +func resultMessage(result api.DupeCheckResult) string { + if result.Error != "" { + return result.Error + } + if result.SkipReason != "" { + return result.SkipReason + } + if result.HasDupes { + return fmt.Sprintf("%d dupes found", len(result.Filtered)) + } + return "no dupes found" +} +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} +func cloneSummary(summary api.DupeCheckSummary) api.DupeCheckSummary { + cloned := summary + cloned.Results = make([]api.DupeCheckResult, len(summary.Results)) + for idx := range summary.Results { + cloned.Results[idx] = cloneResult(summary.Results[idx]) + } + cloned.Notes = append([]string(nil), summary.Notes...) + cloned.Eligibility = cloneTrackerEligibility(summary.Eligibility) + return cloned +} +func cloneResult(result api.DupeCheckResult) api.DupeCheckResult { + result.Raw = append([]api.DupeEntry(nil), result.Raw...) + result.Filtered = append([]api.DupeEntry(nil), result.Filtered...) + result.Notes = append([]string(nil), result.Notes...) + if result.Error != "" { + result.Error = sanitizeMessage(result.Error) + } + return result +} +func cloneDupeState(state DupeCheckTrackerState) DupeCheckTrackerState { + state.Result = cloneResult(state.Result) + return state +} diff --git a/internal/webserver/jobs/dupe_test.go b/internal/webserver/jobs/dupe_test.go new file mode 100644 index 00000000..0d163640 --- /dev/null +++ b/internal/webserver/jobs/dupe_test.go @@ -0,0 +1,179 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +func TestDupeCanonicalResultsDiscoveryAndRepeatedTerminalProgress(t *testing.T) { + t.Parallel() + runner := dupeRunnerFunc(func(ctx context.Context, _ api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + api.EmitDupeProgress(ctx, api.DupeProgressUpdate{ + Tracker: "a", + Status: "running", + Total: 3, + }) + api.EmitDupeProgress(ctx, api.DupeProgressUpdate{ + Tracker: "a", + Status: "completed", + Total: 2, + Result: api.DupeCheckResult{Tracker: "a"}, + }) + api.EmitDupeProgress(ctx, api.DupeProgressUpdate{ + Tracker: "a", + Status: "completed", + Total: 1, + Result: api.DupeCheckResult{Tracker: "a"}, + }) + api.EmitDupeProgress(ctx, api.DupeProgressUpdate{ + Tracker: " discovered ", + Status: "skipped", + Result: api.DupeCheckResult{Tracker: "discovered", Skipped: true}, + }) + return api.DupeCheckSummary{Results: []api.DupeCheckResult{ + { + Tracker: "A", + HasDupes: true, + Filtered: []api.DupeEntry{{}}, + }, + {Tracker: "B", Status: "completed"}, + }}, nil + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + id, err := engine.StartDupe(context.Background(), mustRegisterOwner(t, engine, "owner"), dupeSpec(runner, "a")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if snapshot.Status != StatusCompleted || snapshot.CompletedCount != 3 || snapshot.TotalCount != 3 { + t.Fatalf("counts/status = %#v", snapshot) + } + if len(snapshot.Trackers) != 3 || snapshot.Trackers[0].Tracker != "A" || snapshot.Trackers[1].Tracker != "DISCOVERED" || snapshot.Trackers[2].Tracker != "B" { + t.Fatalf("tracker order = %#v", snapshot.Trackers) + } +} + +func TestDupeCancellationMarksEveryActiveTracker(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + runner := dupeRunnerFunc(func(ctx context.Context, _ api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + api.EmitDupeProgress(ctx, api.DupeProgressUpdate{Tracker: "A", Status: "completed"}) + close(started) + <-ctx.Done() + return api.DupeCheckSummary{}, ctx.Err() + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + id, err := engine.StartDupe(context.Background(), owner, dupeSpec(runner, "A", "B")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + <-started + if err := engine.CancelDupe(owner, id); err != nil { + t.Fatalf("CancelDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if snapshot.Status != StatusCanceled || snapshot.Trackers[0].Status != StatusCompleted || snapshot.Trackers[1].Status != StatusCanceled { + t.Fatalf("snapshot = %#v", snapshot) + } + for _, state := range snapshot.Trackers { + if state.Status == StatusQueued || state.Status == StatusRunning || state.FinishedAt == "" { + t.Fatalf("nonterminal state = %#v", state) + } + } +} + +func TestDupeCoreErrorIsSanitizedAndLeavesNoActiveStates(t *testing.T) { + t.Parallel() + runner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, errors.New("api_key=secret-value") + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + id, err := engine.StartDupe(context.Background(), mustRegisterOwner(t, engine, "owner"), dupeSpec(runner, "A", "B")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if snapshot.Status != StatusFailed || snapshot.Failure == nil || strings.Contains(snapshot.Failure.Message, "secret-value") { + t.Fatalf("snapshot = %#v", snapshot) + } + for _, state := range snapshot.Trackers { + if state.Status != StatusFailed || strings.Contains(state.Message, "secret-value") { + t.Fatalf("state = %#v", state) + } + } +} + +func TestDupeSnapshotPreservesTypedOperationFailure(t *testing.T) { + t.Parallel() + want := api.OperationFailure{ + Code: api.OperationFailureConfirmationRequired, + Operation: api.OperationKindDuplicateCheck, + Message: "Blu-ray playlist changes require confirmation before rescanning.", + Recovery: api.OperationRecoveryConfirm, + } + runner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, api.NewOperationError(want, errors.New("private cause")) + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + id, err := engine.StartDupe(context.Background(), mustRegisterOwner(t, engine, "owner"), dupeSpec(runner, "A")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if snapshot.Failure == nil || *snapshot.Failure != want { + t.Fatalf("failure = %#v, want %#v", snapshot.Failure, want) + } +} + +func TestDupeResultErrorIsSanitizedBeforeSnapshotDelivery(t *testing.T) { + t.Parallel() + runner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{Results: []api.DupeCheckResult{{ + Tracker: "A", + Status: StatusFailed, + Error: "token=secret-value", + }}}, nil + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + id, err := engine.StartDupe(context.Background(), mustRegisterOwner(t, engine, "owner"), dupeSpec(runner, "A")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if strings.Contains(snapshot.Trackers[0].Result.Error, "secret-value") || strings.Contains(snapshot.Summary.Results[0].Error, "secret-value") { + t.Fatalf("secret crossed result snapshot: %#v", snapshot) + } +} + +func TestDupeResourceFailureDoesNotChangeOutcome(t *testing.T) { + t.Parallel() + closer := &closeRecorder{err: errors.New("token=secret-value")} + runner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + spec := dupeSpec(runner, "A") + spec.Resources.Core = closer + id, err := engine.StartDupe(context.Background(), mustRegisterOwner(t, engine, "owner"), spec) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + snapshot := waitDupe(t, engine, id) + if snapshot.Status != StatusCompleted || snapshot.Failure != nil || closer.count.Load() != 1 { + t.Fatalf("snapshot=%#v closes=%d", snapshot, closer.count.Load()) + } +} diff --git a/internal/webserver/jobs/engine.go b/internal/webserver/jobs/engine.go new file mode 100644 index 00000000..8b6c4ff5 --- /dev/null +++ b/internal/webserver/jobs/engine.go @@ -0,0 +1,729 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + completedJobRetention = 24 * time.Hour + maxCompletedJobs = 200 +) + +var ( + // ErrEngineClosed indicates that the engine has begun shutdown and rejects new jobs. + ErrEngineClosed = errors.New("engine closed") + // ErrOwnerClosed indicates that an owner generation is unknown, draining, or closed. + ErrOwnerClosed = errors.New("job owner closed") + // ErrOwnerDraining indicates that a textual owner ID cannot be reused until blocking removal completes. + ErrOwnerDraining = errors.New("job owner draining") + // ErrDupeNotFound indicates that no dupe job is visible to the supplied owner. + ErrDupeNotFound = errors.New("dupe job not found") + // ErrUploadNotFound indicates that no upload job is visible to the supplied owner. + ErrUploadNotFound = errors.New("upload job not found") +) + +// SnapshotSink receives copied job snapshots after state locks are released. +// Implementations route each snapshot within the supplied owner scope. +type SnapshotSink interface { + // EmitDupe publishes the latest dupe-check snapshot for ownerID. + EmitDupe(ownerID string, snapshot DupeCheckSnapshot) + // EmitUpload publishes the latest tracker-upload snapshot for ownerID. + EmitUpload(ownerID string, snapshot TrackerUploadSnapshot) +} + +type diagnosticSink interface { + WarnJob(message string) +} + +// UploadProgressPolicy controls how frequently upload progress snapshots are emitted. +// State is updated for every progress event even when snapshot emission is throttled. +type UploadProgressPolicy struct { + // MinInterval is the minimum delay between ordinary progress emissions; zero disables interval throttling. + MinInterval time.Duration + // HashRateDeltaMiB emits early when the MiB/s rate changes by at least this amount; zero makes every update qualify. + HashRateDeltaMiB float64 +} + +// Config controls kind-specific presentation behavior. Lifecycle retention is +// deliberately fixed module policy: 24 hours and 200 terminal Jobs per owner. +type Config struct { + // UploadProgress controls upload snapshot throttling without dropping state updates. + UploadProgress UploadProgressPolicy +} + +type timer interface{ Stop() bool } + +// engineDeps supplies deterministic time, timer, ID, retention, and diagnostic seams for package tests. +type engineDeps struct { + now func() time.Time + newTimer func(time.Duration, func()) timer + newID func() string + retentionTTL time.Duration + retentionMax int +} + +// OwnerHandle is opaque authority for exactly one live owner generation. +// Stale handles remain closed even after the same textual owner ID is registered again. +type OwnerHandle struct { + engine *Engine + id string + generation uint64 + closed atomic.Bool +} + +// ID returns the normalized textual owner identity used only for scoped event routing. +func (h *OwnerHandle) ID() string { + if h == nil { + return "" + } + return h.id +} + +type jobKind uint8 + +const ( + jobKindDupe jobKind = iota + 1 + jobKindUpload +) + +type ownerState struct { + handle *OwnerHandle + draining bool + records map[string]*jobRecord + terminal []*jobRecord +} + +// jobRecord contains only lifecycle facts shared by all Job kinds. +type jobRecord struct { + id string + kind jobKind + owner *OwnerHandle + accepted time.Time + finished time.Time + cancel context.CancelFunc + done chan struct{} + resources resourceSet + timer timer + terminal bool +} + +// Engine owns owner-scoped upload and duplicate-check lifecycles. +// Its exported methods are safe for concurrent use, and the zero value is not usable. +type Engine struct { + mu sync.Mutex + closing bool + closed bool + closedDone chan struct{} + wg sync.WaitGroup + sink SnapshotSink + config Config + deps engineDeps + generation uint64 + owners map[string]*ownerState + records map[string]*jobRecord + dupes map[string]*dupeJob + uploads map[string]*uploadJob +} + +// New creates a job engine that emits snapshots through sink according to config. +// A nil sink is allowed and disables snapshot delivery. +func New(sink SnapshotSink, config Config) *Engine { + return newEngine(sink, config, engineDeps{ + now: time.Now, + newTimer: func(delay time.Duration, callback func()) timer { return time.AfterFunc(delay, callback) }, + newID: randomID, + retentionTTL: completedJobRetention, + retentionMax: maxCompletedJobs, + }) +} + +func newEngine(sink SnapshotSink, config Config, deps engineDeps) *Engine { + if deps.now == nil { + deps.now = time.Now + } + if deps.newTimer == nil { + deps.newTimer = func(delay time.Duration, callback func()) timer { return time.AfterFunc(delay, callback) } + } + if deps.newID == nil { + deps.newID = randomID + } + if deps.retentionTTL == 0 { + deps.retentionTTL = completedJobRetention + } + if deps.retentionMax == 0 { + deps.retentionMax = maxCompletedJobs + } + return &Engine{ + sink: sink, + config: config, + deps: deps, + closedDone: make(chan struct{}), + owners: make(map[string]*ownerState), + records: make(map[string]*jobRecord), + dupes: make(map[string]*dupeJob), + uploads: make(map[string]*uploadJob), + } +} + +// RegisterOwner returns the live generation for ownerID or creates one. An ID +// cannot be reused while its prior generation is draining. +func (e *Engine) RegisterOwner(ownerID string) (*OwnerHandle, error) { + if e == nil { + return nil, ErrEngineClosed + } + ownerID = strings.TrimSpace(ownerID) + if ownerID == "" { + return nil, errors.New("owner id is required") + } + e.mu.Lock() + defer e.mu.Unlock() + if e.closing { + return nil, ErrEngineClosed + } + if owner := e.owners[ownerID]; owner != nil { + if owner.draining || owner.handle.closed.Load() { + return nil, ErrOwnerDraining + } + return owner.handle, nil + } + e.generation++ + handle := &OwnerHandle{ + engine: e, + id: ownerID, + generation: e.generation, + } + e.owners[ownerID] = &ownerState{handle: handle, records: make(map[string]*jobRecord)} + return handle, nil +} + +func (e *Engine) validateOwnerLocked(handle *OwnerHandle) (*ownerState, error) { + if handle == nil || handle.engine != e || handle.closed.Load() { + return nil, ErrOwnerClosed + } + owner := e.owners[handle.id] + if owner == nil || owner.handle != handle || owner.handle.generation != handle.generation || owner.draining { + return nil, ErrOwnerClosed + } + return owner, nil +} + +// RemoveOwner blocks new enrollment, cancels and drains only handle's Jobs, +// releases their resources, and removes all retained snapshots for that generation. +func (e *Engine) RemoveOwner(handle *OwnerHandle) error { + if e == nil || handle == nil || handle.engine != e { + return ErrOwnerClosed + } + e.mu.Lock() + owner := e.owners[handle.id] + if owner == nil || owner.handle != handle || owner.handle.generation != handle.generation { + e.mu.Unlock() + return ErrOwnerClosed + } + if owner.draining { + records := append([]*jobRecord(nil), ownerRecords(owner)...) + e.mu.Unlock() + waitRecords(records) + return nil + } + owner.draining = true + handle.closed.Store(true) + records := append([]*jobRecord(nil), ownerRecords(owner)...) + for _, record := range records { + if record.timer != nil { + record.timer.Stop() + record.timer = nil + } + } + e.mu.Unlock() + + for _, record := range records { + if record.cancel != nil { + record.cancel() + } + } + waitRecords(records) + + e.mu.Lock() + if current := e.owners[handle.id]; current == owner { + for _, record := range ownerRecords(owner) { + e.deleteRecordLocked(record) + } + delete(e.owners, handle.id) + } + e.mu.Unlock() + return nil +} + +func ownerRecords(owner *ownerState) []*jobRecord { + if owner == nil { + return nil + } + records := make([]*jobRecord, 0, len(owner.records)) + for _, record := range owner.records { + records = append(records, record) + } + return records +} + +func waitRecords(records []*jobRecord) { + for _, record := range records { + if record != nil && record.done != nil { + <-record.done + } + } +} + +func (e *Engine) enroll(parent context.Context, handle *OwnerHandle, kind jobKind, resources Resources) (context.Context, *jobRecord, error) { + if e == nil { + return nil, nil, ErrEngineClosed + } + if handle == nil || handle.engine != e || handle.closed.Load() { + return nil, nil, ErrOwnerClosed + } + jobCtx, cancel := context.WithCancel(parent) + record := &jobRecord{ + id: e.deps.newID(), + kind: kind, + owner: handle, + accepted: e.deps.now().UTC(), + cancel: cancel, + done: make(chan struct{}), + resources: resourceSet{resources: resources}, + } + e.mu.Lock() + if e.closing { + e.mu.Unlock() + cancel() + return nil, nil, ErrEngineClosed + } + owner, err := e.validateOwnerLocked(handle) + if err != nil { + e.mu.Unlock() + cancel() + return nil, nil, err + } + e.records[record.id] = record + owner.records[record.id] = record + e.wg.Add(1) + e.mu.Unlock() + return jobCtx, record, nil +} + +func (e *Engine) start(jobCtx context.Context, record *jobRecord, run func(context.Context), fail func(string), finalize func(), emit func()) { + emit() + go func() { + defer e.wg.Done() + defer record.cancel() + defer func() { + if recovered := recover(); recovered != nil { + fail("job worker panicked: " + sanitizeMessage(fmt.Sprint(recovered))) + } + if err := record.resources.close(); err != nil { + e.warn("job resource cleanup failed: " + sanitizeMessage(err.Error())) + } + finalize() + emit() + e.complete(record) + close(record.done) + }() + run(jobCtx) + }() +} + +// StartUpload validates and clones spec, then enrolls it for asynchronous work. +// Once accepted, the job is detached from caller cancellation and runs until +// explicit cancellation, owner removal, or engine shutdown. Resource ownership +// transfers to the engine only when this method succeeds. +func (e *Engine) StartUpload(ctx context.Context, owner *OwnerHandle, raw UploadSpec) (string, error) { + if ctx == nil { + return "", errors.New("context is required") + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("start upload: %w", err) + } + spec, err := normalizeUploadSpec(raw) + if err != nil { + return "", err + } + jobCtx, record, err := e.enroll(context.WithoutCancel(ctx), owner, jobKindUpload, spec.Resources) + if err != nil { + return "", err + } + job := newUploadJob(record.id, spec, record.accepted, e.config.UploadProgress) + e.mu.Lock() + e.uploads[record.id] = job + e.mu.Unlock() + e.start( + jobCtx, + record, + func(runCtx context.Context) { e.runUpload(runCtx, job) }, + func(message string) { e.failUpload(job, message) }, + func() { e.finalizeUpload(job) }, + func() { e.emitUpload(record, job) }, + ) + return record.id, nil +} + +// StartDupe validates and clones spec, then enrolls it for asynchronous work. +// Once accepted, the job is detached from caller cancellation and runs until +// explicit cancellation, owner removal, or engine shutdown. Resource ownership +// transfers to the engine only when this method succeeds. +func (e *Engine) StartDupe(ctx context.Context, owner *OwnerHandle, raw DupeSpec) (string, error) { + if ctx == nil { + return "", errors.New("context is required") + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("start dupe: %w", err) + } + spec, err := normalizeDupeSpec(raw) + if err != nil { + return "", err + } + jobCtx, record, err := e.enroll(context.WithoutCancel(ctx), owner, jobKindDupe, spec.Resources) + if err != nil { + return "", err + } + job := newDupeJob(record.id, spec, record.accepted) + e.mu.Lock() + e.dupes[record.id] = job + e.mu.Unlock() + e.start( + jobCtx, + record, + func(runCtx context.Context) { e.runDupe(runCtx, job) }, + func(message string) { e.failDupe(job, message) }, + func() { e.finalizeDupe(job) }, + func() { e.emitDupe(record, job) }, + ) + return record.id, nil +} + +func (e *Engine) lookupRecord(owner *OwnerHandle, jobID string, kind jobKind) *jobRecord { + if e == nil || owner == nil || owner.engine != e || owner.closed.Load() { + return nil + } + e.mu.Lock() + defer e.mu.Unlock() + current, err := e.validateOwnerLocked(owner) + if err != nil { + return nil + } + record := current.records[strings.TrimSpace(jobID)] + if record == nil || record.kind != kind || record.owner != owner { + return nil + } + return record +} + +// UploadSnapshot returns a defensive snapshot when jobID belongs to owner. +func (e *Engine) UploadSnapshot(owner *OwnerHandle, jobID string) (TrackerUploadSnapshot, error) { + record := e.lookupRecord(owner, jobID, jobKindUpload) + if record == nil { + return TrackerUploadSnapshot{}, ErrUploadNotFound + } + e.mu.Lock() + job := e.uploads[record.id] + e.mu.Unlock() + if job == nil { + return TrackerUploadSnapshot{}, ErrUploadNotFound + } + return job.snapshot(), nil +} + +// DupeSnapshot returns a defensive snapshot when jobID belongs to owner. +func (e *Engine) DupeSnapshot(owner *OwnerHandle, jobID string) (DupeCheckSnapshot, error) { + record := e.lookupRecord(owner, jobID, jobKindDupe) + if record == nil { + return DupeCheckSnapshot{}, ErrDupeNotFound + } + e.mu.Lock() + job := e.dupes[record.id] + e.mu.Unlock() + if job == nil { + return DupeCheckSnapshot{}, ErrDupeNotFound + } + return job.snapshot(), nil +} + +// List returns immutable retained Jobs for one owner in acceptance order. +func (e *Engine) List(owner *OwnerHandle) ([]OwnerJobSnapshot, error) { + if e == nil { + return nil, ErrEngineClosed + } + e.mu.Lock() + state, err := e.validateOwnerLocked(owner) + if err != nil { + e.mu.Unlock() + return nil, err + } + records := append([]*jobRecord(nil), ownerRecords(state)...) + e.mu.Unlock() + + sort.Slice(records, func(left, right int) bool { + if records[left].accepted.Equal(records[right].accepted) { + return records[left].id < records[right].id + } + return records[left].accepted.Before(records[right].accepted) + }) + result := make([]OwnerJobSnapshot, 0, len(records)) + for _, record := range records { + switch record.kind { + case jobKindDupe: + snapshot, snapshotErr := e.DupeSnapshot(owner, record.id) + if snapshotErr != nil { + continue + } + result = append(result, OwnerJobSnapshot{ + Kind: KindDuplicateCheck, + JobID: snapshot.JobID, + CorrelationID: snapshot.CorrelationID, + Release: snapshot.Release, + Status: snapshot.Status, + StartedAt: snapshot.StartedAt, + FinishedAt: snapshot.FinishedAt, + Dupe: &snapshot, + }) + case jobKindUpload: + snapshot, snapshotErr := e.UploadSnapshot(owner, record.id) + if snapshotErr != nil { + continue + } + result = append(result, OwnerJobSnapshot{ + Kind: KindTrackerUpload, + JobID: snapshot.JobID, + CorrelationID: snapshot.CorrelationID, + RetryOf: snapshot.RetryOf, + Release: snapshot.Release, + Status: snapshot.Status, + StartedAt: snapshot.StartedAt, + FinishedAt: snapshot.FinishedAt, + Upload: &snapshot, + }) + } + } + return result, nil +} + +// CancelUpload requests cancellation of an owner-scoped upload Job. +func (e *Engine) CancelUpload(owner *OwnerHandle, jobID string) error { + record := e.lookupRecord(owner, jobID, jobKindUpload) + if record == nil { + return ErrUploadNotFound + } + record.cancel() + return nil +} + +// CancelDupe requests cancellation of an owner-scoped duplicate-check Job. +func (e *Engine) CancelDupe(owner *OwnerHandle, jobID string) error { + record := e.lookupRecord(owner, jobID, jobKindDupe) + if record == nil { + return ErrDupeNotFound + } + record.cancel() + return nil +} + +// UploadRetry returns cloned input for retry-eligible failures without retaining resources or runners. +func (e *Engine) UploadRetry(owner *OwnerHandle, jobID string) (UploadRetry, error) { + record := e.lookupRecord(owner, jobID, jobKindUpload) + if record == nil { + return UploadRetry{}, ErrUploadNotFound + } + e.mu.Lock() + job := e.uploads[record.id] + e.mu.Unlock() + if job == nil { + return UploadRetry{}, ErrUploadNotFound + } + return job.retry() +} + +func (e *Engine) emitUpload(record *jobRecord, job *uploadJob) { + if e.sink == nil || record == nil || job == nil { + return + } + snapshot := job.snapshot() + e.safeSink(func() { e.sink.EmitUpload(record.owner.id, snapshot) }) +} + +func (e *Engine) emitUploadJob(job *uploadJob) { + if job == nil { + return + } + e.mu.Lock() + record := e.records[job.id] + e.mu.Unlock() + e.emitUpload(record, job) +} + +func (e *Engine) emitDupe(record *jobRecord, job *dupeJob) { + if e.sink == nil || record == nil || job == nil { + return + } + snapshot := job.snapshot() + e.safeSink(func() { e.sink.EmitDupe(record.owner.id, snapshot) }) +} + +func (e *Engine) emitDupeJob(job *dupeJob) { + if job == nil { + return + } + e.mu.Lock() + record := e.records[job.id] + e.mu.Unlock() + e.emitDupe(record, job) +} + +func (e *Engine) safeSink(emit func()) { + defer func() { + if recovered := recover(); recovered != nil { + e.warn("job snapshot sink panicked: " + sanitizeMessage(fmt.Sprint(recovered))) + } + }() + emit() +} + +func (e *Engine) warn(message string) { + message = sanitizeMessage(message) + if sink, ok := e.sink.(diagnosticSink); ok { + defer func() { _ = recover() }() + sink.WarnJob(message) + } +} + +func (e *Engine) complete(record *jobRecord) { + e.mu.Lock() + defer e.mu.Unlock() + current := e.records[record.id] + if current != record { + return + } + record.terminal = true + record.finished = e.deps.now().UTC() + owner := e.owners[record.owner.id] + if owner == nil || owner.handle != record.owner || owner.draining || e.closing { + return + } + owner.terminal = append(owner.terminal, record) + e.pruneOwnerLocked(owner) + if e.deps.retentionTTL > 0 { + var ownedTimer timer + ownedTimer = e.deps.newTimer(e.deps.retentionTTL, func() { + e.mu.Lock() + defer e.mu.Unlock() + if e.closing || e.records[record.id] != record || record.timer != ownedTimer || !record.terminal { + return + } + e.deleteRecordLocked(record) + }) + record.timer = ownedTimer + } +} + +func (e *Engine) pruneOwnerLocked(owner *ownerState) { + limit := e.deps.retentionMax + if owner == nil || limit <= 0 { + return + } + for len(owner.terminal) > limit { + oldest := owner.terminal[0] + owner.terminal = owner.terminal[1:] + e.deleteRecordLocked(oldest) + } +} + +func (e *Engine) deleteRecordLocked(record *jobRecord) { + if record == nil || e.records[record.id] != record { + return + } + if record.timer != nil { + record.timer.Stop() + record.timer = nil + } + delete(e.records, record.id) + delete(e.dupes, record.id) + delete(e.uploads, record.id) + if owner := e.owners[record.owner.id]; owner != nil && owner.handle == record.owner { + delete(owner.records, record.id) + for idx, terminal := range owner.terminal { + if terminal == record { + owner.terminal = append(owner.terminal[:idx], owner.terminal[idx+1:]...) + break + } + } + } +} + +// Close atomically rejects enrollment, cancels all work, blocks through worker +// and resource release, and discards all owner generations and snapshots. +func (e *Engine) Close() { + if e == nil { + return + } + e.mu.Lock() + if e.closed { + done := e.closedDone + e.mu.Unlock() + <-done + return + } + if e.closing { + done := e.closedDone + e.mu.Unlock() + <-done + return + } + e.closing = true + records := make([]*jobRecord, 0, len(e.records)) + for _, record := range e.records { + if record.timer != nil { + record.timer.Stop() + record.timer = nil + } + records = append(records, record) + } + for _, owner := range e.owners { + owner.draining = true + owner.handle.closed.Store(true) + } + e.mu.Unlock() + + for _, record := range records { + if record.cancel != nil { + record.cancel() + } + } + e.wg.Wait() + waitRecords(records) + + e.mu.Lock() + e.records = make(map[string]*jobRecord) + e.dupes = make(map[string]*dupeJob) + e.uploads = make(map[string]*uploadJob) + e.owners = make(map[string]*ownerState) + e.closed = true + close(e.closedDone) + e.mu.Unlock() +} + +func randomID() string { + value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(^uint64(0))) + if err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return fmt.Sprintf("%d-%x", time.Now().UnixNano(), value.Uint64()) +} diff --git a/internal/webserver/jobs/engine_test.go b/internal/webserver/jobs/engine_test.go new file mode 100644 index 00000000..1cf6405a --- /dev/null +++ b/internal/webserver/jobs/engine_test.go @@ -0,0 +1,455 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +type recordingSink struct { + mu sync.Mutex + uploads []TrackerUploadSnapshot + dupes []DupeCheckSnapshot + warnings []string +} + +func (s *recordingSink) WarnJob(message string) { + s.mu.Lock() + defer s.mu.Unlock() + s.warnings = append(s.warnings, message) +} + +func (s *recordingSink) EmitUpload(_ string, snapshot TrackerUploadSnapshot) { + s.mu.Lock() + defer s.mu.Unlock() + s.uploads = append(s.uploads, snapshot) +} + +func (s *recordingSink) EmitDupe(_ string, snapshot DupeCheckSnapshot) { + s.mu.Lock() + defer s.mu.Unlock() + s.dupes = append(s.dupes, snapshot) +} + +func mustRegisterOwner(t *testing.T, engine *Engine, ownerID string) *OwnerHandle { + t.Helper() + owner, err := engine.RegisterOwner(ownerID) + if err != nil { + t.Fatalf("RegisterOwner(%q): %v", ownerID, err) + } + return owner +} + +type uploadRunnerFunc func(context.Context, api.UploadExecutionPlan) (api.Result, error) + +func (f uploadRunnerFunc) RunUpload(ctx context.Context, request api.UploadExecutionPlan) (api.Result, error) { + return f(ctx, request) +} + +type dupeRunnerFunc func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) + +func (f dupeRunnerFunc) CheckDupes(ctx context.Context, request api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return f(ctx, request) +} + +type closeRecorder struct { + count atomic.Int32 + err error + panicValue any + started chan struct{} + release <-chan struct{} +} + +func (c *closeRecorder) Close() error { + c.count.Add(1) + if c.started != nil { + close(c.started) + } + if c.release != nil { + <-c.release + } + if c.panicValue != nil { + panic(c.panicValue) + } + return c.err +} + +func TestTerminalStateWaitsForResourceRelease(t *testing.T) { + t.Parallel() + cleanupStarted := make(chan struct{}) + cleanupRelease := make(chan struct{}) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + id, err := engine.StartUpload(context.Background(), owner, UploadSpec{ + CorrelationID: "cleanup-correlation", + Snapshot: testUploadSnapshot("Example Release 2026"), + Runner: uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{UploadedCount: 1}, nil + }), + Resources: Resources{Core: &closeRecorder{started: cleanupStarted, release: cleanupRelease}}, + }) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + <-cleanupStarted + snapshot, err := engine.UploadSnapshot(owner, id) + if err != nil { + t.Fatalf("UploadSnapshot: %v", err) + } + if isJobTerminal(snapshot.Status) || snapshot.FinishedAt != "" { + t.Fatalf("terminal state published before cleanup: %#v", snapshot) + } + close(cleanupRelease) + if snapshot = waitUpload(t, engine, id); snapshot.Status != StatusCompleted { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func uploadSpec(runner UploadRunner, trackers ...string) UploadSpec { + sourcePath := `C:\Example\Example.Release.2026.1080p-GRP` + snapshot := testUploadSnapshot(sourcePath) + snapshot.Input.Trackers = append([]string(nil), trackers...) + return UploadSpec{ +CorrelationID: "upload-correlation", + Snapshot: snapshot, + Runner: runner, +} +} + +func dupeSpec(runner DupeRunner, trackers ...string) DupeSpec { + sourcePath := `C:\Example\Example.Release.2026.1080p-GRP` + snapshot := testDupeSnapshot(sourcePath) + snapshot.Input.Trackers = append([]string(nil), trackers...) + return DupeSpec{ +CorrelationID: "dupe-correlation", + Snapshot: snapshot, + Runner: runner, +} +} + +func testUploadSnapshot(sourcePath string) UploadExecutionSnapshot { + return UploadExecutionSnapshot{ + PreparedGeneration: 1, + RuntimeGeneration: 1, + Input: api.UploadReviewInput{ + Release: api.ReleaseRef{SourcePath: sourcePath, Generation: 1}, + Trackers: []string{"A"}, + }, + ReviewToken: "test-review-token", + } +} + +func testDupeSnapshot(sourcePath string) DuplicateExecutionSnapshot { + return DuplicateExecutionSnapshot{ + PreparedGeneration: 1, + RuntimeGeneration: 1, + Input: api.DuplicateCheckInput{ + Release: api.ReleaseRef{SourcePath: sourcePath, Generation: 1}, + Trackers: []string{"A"}, + }, + } +} + +func waitUpload(t *testing.T, engine *Engine, id string) TrackerUploadSnapshot { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + snapshot, err := engine.UploadSnapshot(mustRegisterOwner(t, engine, "owner"), id) + if err == nil && isJobTerminal(snapshot.Status) { + return snapshot + } + time.Sleep(time.Millisecond) + } + t.Fatal("upload did not become terminal") + return TrackerUploadSnapshot{} +} + +func waitDupe(t *testing.T, engine *Engine, id string) DupeCheckSnapshot { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + snapshot, err := engine.DupeSnapshot(mustRegisterOwner(t, engine, "owner"), id) + if err == nil && isJobTerminal(snapshot.Status) { + return snapshot + } + time.Sleep(time.Millisecond) + } + t.Fatal("dupe check did not become terminal") + return DupeCheckSnapshot{} +} + +func TestEngineValidatesInputsAndIsolatesOwners(t *testing.T) { + t.Parallel() + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + runner := uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { return api.Result{}, nil }) + + if _, err := engine.RegisterOwner(""); err == nil { + t.Fatal("expected owner validation error") + } + owner := mustRegisterOwner(t, engine, "owner") + if _, err := engine.StartUpload(context.Background(), owner, uploadSpec(runner)); err == nil { + t.Fatal("expected tracker validation error") + } + id, err := engine.StartUpload(context.Background(), owner, uploadSpec(runner, " A ", "a", "B")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + other := mustRegisterOwner(t, engine, "other") + if _, err := engine.UploadSnapshot(other, id); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("wrong-owner snapshot error = %v", err) + } + if err := engine.CancelUpload(other, id); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("wrong-owner cancel error = %v", err) + } + if _, err := engine.UploadRetry(other, id); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("wrong-owner retry error = %v", err) + } + snapshot := waitUpload(t, engine, id) + if len(snapshot.Trackers) != 2 || snapshot.Trackers[0].Tracker != "A" || snapshot.Trackers[1].Tracker != "B" { + t.Fatalf("normalized trackers = %#v", snapshot.Trackers) + } +} + +func TestEngineCloseWaitsCancelsAndRejectsStarts(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + close(started) + <-ctx.Done() + return api.Result{}, ctx.Err() + }) + core, logger := &closeRecorder{}, &closeRecorder{} + engine := New(nil, Config{}) + owner := mustRegisterOwner(t, engine, "owner") + _, err := engine.StartUpload(context.Background(), owner, UploadSpec{ + CorrelationID: "rejected-correlation", + Snapshot: testUploadSnapshot("Example Release 2026"), + Runner: runner, + Resources: Resources{Core: core, Logger: logger}, + }) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + <-started + engine.Close() + if _, err := engine.UploadSnapshot(owner, "discarded"); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("snapshot after close error = %v", err) + } + if core.count.Load() != 1 || logger.count.Load() != 1 { + t.Fatalf("close counts core=%d logger=%d", core.count.Load(), logger.count.Load()) + } + if _, err := engine.RegisterOwner("new-owner"); !errors.Is(err, ErrEngineClosed) { + t.Fatalf("register after close error = %v", err) + } + engine.Close() +} + +func TestOwnerRemovalDrainsAndStaleHandleNeverRevives(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + core := &closeRecorder{} + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "session") + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + close(started) + <-ctx.Done() + <-release + return api.Result{}, ctx.Err() + }) + jobID, err := engine.StartUpload(context.Background(), owner, UploadSpec{ + CorrelationID: "owner-removal-correlation", + Snapshot: testUploadSnapshot("Example Release 2026"), + Runner: runner, + Resources: Resources{Core: core}, + }) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + <-started + done := make(chan error, 1) + go func() { done <- engine.RemoveOwner(owner) }() + + deadline := time.Now().Add(3 * time.Second) + for !owner.closed.Load() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !owner.closed.Load() { + t.Fatal("owner did not enter draining state") + } + if _, err := engine.RegisterOwner("session"); !errors.Is(err, ErrOwnerDraining) { + t.Fatalf("register during drain error = %v", err) + } + select { + case err := <-done: + t.Fatalf("RemoveOwner returned before worker release: %v", err) + default: + } + close(release) + if err := <-done; err != nil { + t.Fatalf("RemoveOwner: %v", err) + } + if core.count.Load() != 1 { + t.Fatalf("core close count = %d", core.count.Load()) + } + if _, err := engine.UploadSnapshot(owner, jobID); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("stale snapshot error = %v", err) + } + if _, err := engine.StartUpload(context.Background(), owner, uploadSpec(runner, "A")); !errors.Is(err, ErrOwnerClosed) { + t.Fatalf("stale start error = %v", err) + } + reused := mustRegisterOwner(t, engine, "session") + if reused == owner || reused.generation == owner.generation { + t.Fatal("same textual owner ID revived the stale generation") + } +} + +func TestAcceptedJobSurvivesCallerCancellation(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + close(started) + select { + case <-ctx.Done(): + return api.Result{}, errors.New("Job context canceled by caller") + case <-release: + return api.Result{UploadedCount: 1}, nil + } + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + requestCtx, cancel := context.WithCancel(context.Background()) + id, err := engine.StartUpload(requestCtx, owner, uploadSpec(runner, "A")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + <-started + cancel() + close(release) + if snapshot := waitUpload(t, engine, id); snapshot.Status != StatusCompleted { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestCleanupFailureWarnsWithoutRewritingOutcome(t *testing.T) { + t.Parallel() + sink := &recordingSink{} + engine := New(sink, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + id, err := engine.StartUpload(context.Background(), owner, UploadSpec{ + CorrelationID: "panic-correlation", + Snapshot: testUploadSnapshot("Example Release 2026"), + Runner: uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{UploadedCount: 1}, nil + }), + Resources: Resources{Core: &closeRecorder{err: errors.New("token=secret-value")}}, + }) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + if snapshot := waitUpload(t, engine, id); snapshot.Status != StatusCompleted { + t.Fatalf("snapshot = %#v", snapshot) + } + sink.mu.Lock() + defer sink.mu.Unlock() + if len(sink.warnings) != 1 || strings.Contains(sink.warnings[0], "secret-value") { + t.Fatalf("warnings = %#v", sink.warnings) + } +} + +func TestOwnerChurnLeavesNoIndexEntries(t *testing.T) { + t.Parallel() + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + for idx := range 100 { + owner := mustRegisterOwner(t, engine, fmt.Sprintf("owner-%d", idx)) + if err := engine.RemoveOwner(owner); err != nil { + t.Fatalf("RemoveOwner(%d): %v", idx, err) + } + } + engine.mu.Lock() + defer engine.mu.Unlock() + if len(engine.owners) != 0 || len(engine.records) != 0 { + t.Fatalf("owner index=%d records=%d", len(engine.owners), len(engine.records)) + } +} + +func TestListIsOwnerScopedOrderedAndCarriesCorrelationRetryLinkage(t *testing.T) { + t.Parallel() + var sequence atomic.Int64 + engine := newEngine(nil, Config{}, engineDeps{ + now: func() time.Time { return time.Unix(sequence.Add(1), 0) }, + newID: func() string { return fmt.Sprintf("job-%d", sequence.Add(1)) }, + retentionTTL: time.Hour, + retentionMax: 200, + }) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + other := mustRegisterOwner(t, engine, "owner-b") + + failed := uploadSpec(uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{}, trackerLocalUploadTestError{message: "failed"} + }), "A") + failed.CorrelationID = "start-correlation" + originalID, err := engine.StartUpload(context.Background(), owner, failed) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + _ = waitUpload(t, engine, originalID) + + retry, err := engine.UploadRetry(owner, originalID) + if err != nil { + t.Fatalf("UploadRetry: %v", err) + } + retryID, err := engine.StartUpload(context.Background(), owner, retry.Spec( + "retry-correlation", + uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{UploadedCount: 1}, nil + }), + Resources{}, + )) + if err != nil { + t.Fatalf("StartUpload retry: %v", err) + } + _ = waitUpload(t, engine, retryID) + + listed, err := engine.List(owner) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(listed) != 2 || listed[0].JobID != originalID || listed[1].JobID != retryID { + t.Fatalf("acceptance order = %#v", listed) + } + if listed[0].CorrelationID != "start-correlation" || listed[1].CorrelationID != "retry-correlation" || listed[1].RetryOf != originalID { + t.Fatalf("correlation/retry linkage = %#v", listed) + } + foreign, err := engine.List(other) + if err != nil || len(foreign) != 0 { + t.Fatalf("foreign owner listing = %#v, %v", foreign, err) + } + + listed[0].Upload.Trackers[0].Tracker = "mutated" + listedAgain, err := engine.List(owner) + if err != nil { + t.Fatalf("List again: %v", err) + } + if listedAgain[0].Upload.Trackers[0].Tracker != "A" { + t.Fatal("owner listing exposed mutable retained state") + } +} diff --git a/internal/webserver/jobs/failure.go b/internal/webserver/jobs/failure.go new file mode 100644 index 00000000..c8e388cb --- /dev/null +++ b/internal/webserver/jobs/failure.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import "github.com/autobrr/upbrr/pkg/api" + +// failureForError preserves a structured operation failure as a defensive copy +// and otherwise creates a frontend-safe generic failure. +func failureForError(err error, operation api.OperationKind, message string, recovery api.OperationRecovery) *api.OperationFailure { + if failure, ok := api.AsOperationFailure(err); ok { + cloned := failure + return &cloned + } + return genericFailure(operation, message, recovery) +} + +func genericFailure(operation api.OperationKind, message string, recovery api.OperationRecovery) *api.OperationFailure { + return &api.OperationFailure{ + Code: api.OperationFailureInternal, + Operation: operation, + Message: message, + Recovery: recovery, + } +} + +func cloneFailure(failure *api.OperationFailure) *api.OperationFailure { + if failure == nil { + return nil + } + cloned := *failure + return &cloned +} diff --git a/internal/webserver/jobs/input.go b/internal/webserver/jobs/input.go new file mode 100644 index 00000000..32b21bea --- /dev/null +++ b/internal/webserver/jobs/input.go @@ -0,0 +1,360 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "errors" + "fmt" + "maps" + "path/filepath" + "reflect" + "strings" + + "github.com/autobrr/upbrr/internal/preparedrelease" + "github.com/autobrr/upbrr/pkg/api" +) + +// UploadRunner executes one prepared upload request for the engine. +type UploadRunner interface { + // RunUpload executes one exact reviewed input and reports progress through the context callback. + RunUpload(context.Context, api.UploadExecutionPlan) (api.Result, error) +} + +// DupeRunner executes one duplicate-check request for the engine. +type DupeRunner interface { + // CheckDupes executes one exact duplicate-check input and reports progress through the context callback. + CheckDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) +} + +// Closer releases one per-job resource after its worker exits. +type Closer interface { + // Close releases the resource and reports cleanup failure. + Close() error +} + +// Resources names the per-job resources whose ownership transfers to the engine after a successful start. +type Resources struct { + // Core is closed before Logger when job execution ends. + Core Closer + // Logger is closed after Core when job execution ends. + Logger Closer +} + +// DuplicateExecutionSnapshot binds accepted duplicate work to one exact +// preparation and runtime generation. It contains no live runtime pointers. +type DuplicateExecutionSnapshot struct { + Seed preparedrelease.Seed + PreparedGeneration api.PreparedGeneration + RuntimeGeneration uint64 + Input api.DuplicateCheckInput +} + +// UploadExecutionSnapshot binds reviewed upload work and required outcomes to +// one exact preparation/runtime generation. +type UploadExecutionSnapshot struct { + Seed preparedrelease.Seed + PreparedGeneration api.PreparedGeneration + RuntimeGeneration uint64 + Input api.UploadReviewInput + Outcome api.UploadReviewOutcome + ReviewToken string +} + +// UploadSpec contains immutable input and execution dependencies for a tracker-upload job. +// The engine validates and clones mutable fields before accepting the job. +type UploadSpec struct { + // CorrelationID is the caller-generated opaque identity for start reconciliation. + CorrelationID string + // RetryOf links this Job to the original failed upload Job, when applicable. + RetryOf string + // Snapshot binds this accepted upload to immutable preparation/runtime state. + Snapshot UploadExecutionSnapshot + // Runner executes one operation-wide request for all selected trackers. + Runner UploadRunner + // Resources are closed exactly once after worker exit. + Resources Resources +} + +// UploadRetry is a cloned, resource-free description of failed tracker work eligible for a fresh job. +type UploadRetry struct { + // RetryOf identifies the original failed upload Job. + RetryOf string + // Snapshot preserves the exact reviewed preparation/runtime lineage. A + // retry is rejected by its runner if this snapshot is no longer compatible. + Snapshot UploadExecutionSnapshot +} + +// Spec builds a fresh upload spec from retained retry input and newly-created +// per-run resources. Runners/resources from the original job are never reused. +func (r UploadRetry) Spec(correlationID string, runner UploadRunner, resources Resources) UploadSpec { + return UploadSpec{ + CorrelationID: correlationID, + RetryOf: r.RetryOf, + Snapshot: cloneUploadExecutionSnapshot(r.Snapshot), + Runner: runner, + Resources: resources, + } +} + +// DupeSpec contains immutable input and execution dependencies for a duplicate-check job. +// The engine validates and clones mutable fields before accepting the job. +type DupeSpec struct { + // CorrelationID is the caller-generated opaque identity for start reconciliation. + CorrelationID string + // Snapshot binds this accepted duplicate check to immutable preparation/runtime state. + Snapshot DuplicateExecutionSnapshot + // Runner executes the duplicate check. + Runner DupeRunner + // Resources are closed exactly once after worker exit. + Resources Resources +} + +// normalizeUploadSpec validates upload input and deep-copies every mutable caller-owned field. +func normalizeUploadSpec(spec UploadSpec) (UploadSpec, error) { + correlationID, err := normalizeCorrelationID(spec.CorrelationID) + if err != nil { + return UploadSpec{}, err + } + spec.CorrelationID = correlationID + spec.RetryOf = strings.TrimSpace(spec.RetryOf) + sourcePath := strings.TrimSpace(spec.Snapshot.Input.Release.SourcePath) + if sourcePath == "" { + return UploadSpec{}, errors.New("path is required") + } + if spec.Runner == nil { + return UploadSpec{}, errors.New("upload runner is required") + } + if err := validateExecutionLineage( + sourcePath, + spec.Snapshot.PreparedGeneration, + spec.Snapshot.RuntimeGeneration, + spec.Snapshot.Input.Release, + ); err != nil { + return UploadSpec{}, fmt.Errorf("upload snapshot: %w", err) + } + if strings.TrimSpace(spec.Snapshot.ReviewToken) == "" { + return UploadSpec{}, errors.New("upload snapshot: review token is required") + } + spec.Snapshot = cloneUploadExecutionSnapshot(spec.Snapshot) + spec.Snapshot.Input.Release.SourcePath = sourcePath + spec.Snapshot.Input.Trackers = normalizeTrackers(spec.Snapshot.Input.Trackers, false) + if len(spec.Snapshot.Input.Trackers) == 0 { + return UploadSpec{}, errors.New("at least one tracker must be selected") + } + spec.Snapshot.Input.IgnoreDupesFor = normalizeTrackers(spec.Snapshot.Input.IgnoreDupesFor, false) + spec.Snapshot.Input.RuleAuthorizations = normalizeRuleAuthorizations(spec.Snapshot.Input.RuleAuthorizations) + return spec, nil +} + +// normalizeDupeSpec validates duplicate-check input and deep-copies mutable caller-owned fields. +func normalizeDupeSpec(spec DupeSpec) (DupeSpec, error) { + correlationID, err := normalizeCorrelationID(spec.CorrelationID) + if err != nil { + return DupeSpec{}, err + } + spec.CorrelationID = correlationID + sourcePath := strings.TrimSpace(spec.Snapshot.Input.Release.SourcePath) + if sourcePath == "" { + return DupeSpec{}, errors.New("path is required") + } + if spec.Runner == nil { + return DupeSpec{}, errors.New("dupe runner is required") + } + if err := validateExecutionLineage( + sourcePath, + spec.Snapshot.PreparedGeneration, + spec.Snapshot.RuntimeGeneration, + spec.Snapshot.Input.Release, + ); err != nil { + return DupeSpec{}, fmt.Errorf("dupe snapshot: %w", err) + } + spec.Snapshot = cloneDuplicateExecutionSnapshot(spec.Snapshot) + spec.Snapshot.Input.Release.SourcePath = sourcePath + spec.Snapshot.Input.Trackers = normalizeTrackers(spec.Snapshot.Input.Trackers, true) + if len(spec.Snapshot.Input.Trackers) == 0 { + return DupeSpec{}, errors.New("at least one tracker must be selected") + } + spec.Snapshot.Input.IgnoreFor = normalizeTrackers(spec.Snapshot.Input.IgnoreFor, true) + return spec, nil +} + +func validateExecutionLineage( + sourcePath string, + preparedGeneration api.PreparedGeneration, + runtimeGeneration uint64, + release api.ReleaseRef, +) error { + if preparedGeneration == 0 || runtimeGeneration == 0 || release.Generation == 0 { + return errors.New("prepared and runtime generations are required") + } + if preparedGeneration != release.Generation { + return errors.New("prepared generation differs from operation release") + } + if !strings.EqualFold(filepath.Clean(strings.TrimSpace(sourcePath)), filepath.Clean(strings.TrimSpace(release.SourcePath))) { + return errors.New("operation release source differs from job source") + } + return nil +} + +func cloneDuplicateExecutionSnapshot(value DuplicateExecutionSnapshot) DuplicateExecutionSnapshot { + value.Input.Trackers = append([]string(nil), value.Input.Trackers...) + value.Input.IgnoreFor = append([]string(nil), value.Input.IgnoreFor...) + value.Input.Authorizations = append([]string(nil), value.Input.Authorizations...) + return value +} + +func cloneUploadExecutionSnapshot(value UploadExecutionSnapshot) UploadExecutionSnapshot { + value.Input.Trackers = append([]string(nil), value.Input.Trackers...) + value.Input.IgnoreDupesFor = append([]string(nil), value.Input.IgnoreDupesFor...) + value.Input.RuleAuthorizations = cloneRuleAuthorizations(value.Input.RuleAuthorizations) + value.Input.QuestionnaireAnswers = cloneAnswers(value.Input.QuestionnaireAnswers) + value.Input.DescriptionGroups = api.CloneDescriptionBuilderGroups(value.Input.DescriptionGroups) + value.Input.TrackerConfigOverrides = clonePointerStruct(value.Input.TrackerConfigOverrides) + value.Input.TrackerSiteOverrides.TIK = clonePointerStruct(value.Input.TrackerSiteOverrides.TIK) + value.Input.ClientOverrides = clonePointerStruct(value.Input.ClientOverrides) + value.Input.ImageHostOverrides = clonePointerStruct(value.Input.ImageHostOverrides) + value.Input.ScreenshotOverrides = cloneScreenshotOverrides(value.Input.ScreenshotOverrides) + value.Input.TorrentOverrides = clonePointerStruct(value.Input.TorrentOverrides) + value.Outcome.ResolvedTrackers = append([]string(nil), value.Outcome.ResolvedTrackers...) + value.Outcome.Eligibility = cloneTrackerEligibility(value.Outcome.Eligibility) + value.Outcome.MatchedTrackers = append([]string(nil), value.Outcome.MatchedTrackers...) + value.Outcome.BlockedTrackers = cloneBlockedTrackers(value.Outcome.BlockedTrackers) + value.Outcome.TrackerRuleFailures = cloneRuleFailures(value.Outcome.TrackerRuleFailures) + value.Outcome.CrossSeedTorrents = append([]api.UploadedTorrent(nil), value.Outcome.CrossSeedTorrents...) + return value +} + +func cloneTrackerEligibility(value api.TrackerEligibility) api.TrackerEligibility { + value.EligibleTrackers = append([]string(nil), value.EligibleTrackers...) + value.Trackers = append([]api.TrackerEligibilityState(nil), value.Trackers...) + for index := range value.Trackers { + value.Trackers[index].Reasons = append([]api.TrackerEligibilityReason(nil), value.Trackers[index].Reasons...) + value.Trackers[index].RuleDecisions = append([]api.RuleDecision(nil), value.Trackers[index].RuleDecisions...) + } + return value +} + +func cloneRuleAuthorizations(values []api.RuleAuthorization) []api.RuleAuthorization { + if len(values) == 0 { + return nil + } + cloned := make([]api.RuleAuthorization, len(values)) + for idx, value := range values { + cloned[idx] = api.RuleAuthorization{Tracker: value.Tracker, Rules: append([]string(nil), value.Rules...)} + } + return cloned +} + +func normalizeRuleAuthorizations(values []api.RuleAuthorization) []api.RuleAuthorization { + cloned := cloneRuleAuthorizations(values) + for idx := range cloned { + cloned[idx].Tracker = strings.ToUpper(strings.TrimSpace(cloned[idx].Tracker)) + for ruleIdx := range cloned[idx].Rules { + cloned[idx].Rules[ruleIdx] = strings.TrimSpace(cloned[idx].Rules[ruleIdx]) + } + } + return cloned +} + +func cloneBlockedTrackers(input map[string][]api.TrackerBlockReason) map[string][]api.TrackerBlockReason { + if input == nil { + return nil + } + result := make(map[string][]api.TrackerBlockReason, len(input)) + for tracker, reasons := range input { + result[tracker] = append([]api.TrackerBlockReason(nil), reasons...) + } + return result +} + +func cloneRuleFailures(input map[string][]api.RuleFailure) map[string][]api.RuleFailure { + if input == nil { + return nil + } + result := make(map[string][]api.RuleFailure, len(input)) + for tracker, failures := range input { + result[tracker] = append([]api.RuleFailure(nil), failures...) + } + return result +} + +func cloneScreenshotOverrides(value api.ScreenshotOverrides) api.ScreenshotOverrides { + value.ManualFrames = append([]int(nil), value.ManualFrames...) + value.ComparisonPaths = append([]string(nil), value.ComparisonPaths...) + value.ComparisonPrimaryIndex = cloneIntPointer(value.ComparisonPrimaryIndex) + value.MenuPaths = append([]string(nil), value.MenuPaths...) + return value +} + +func cloneIntPointer(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func normalizeCorrelationID(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("correlation id is required") + } + if len(value) > 128 { + return "", errors.New("correlation id is too long") + } + return value, nil +} + +// normalizeTrackers trims, removes blanks, and deduplicates tracker names case-insensitively. +func normalizeTrackers(input []string, upper bool) []string { + seen := make(map[string]struct{}, len(input)) + result := make([]string, 0, len(input)) + for _, raw := range input { + tracker := strings.TrimSpace(raw) + key := strings.ToUpper(tracker) + if tracker == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if upper { + tracker = key + } + result = append(result, tracker) + } + return result +} + +// cloneAnswers deep-copies tracker questionnaire maps while preserving explicit empty inner maps. +func cloneAnswers(input map[string]map[string]string) map[string]map[string]string { + if len(input) == 0 { + return nil + } + result := make(map[string]map[string]string, len(input)) + for tracker, answers := range input { + result[tracker] = maps.Clone(answers) + if result[tracker] == nil { + result[tracker] = map[string]string{} + } + } + return result +} + +// clonePointerStruct deep-copies scalar pointer fields in API override value +// structs. Those structs intentionally contain no slices, maps, or interfaces. +func clonePointerStruct[T any](input T) T { + value := reflect.ValueOf(&input).Elem() + for _, field := range value.Fields() { + if field.Kind() != reflect.Pointer || field.IsNil() { + continue + } + pointer := reflect.New(field.Elem().Type()) + pointer.Elem().Set(field.Elem()) + field.Set(pointer) + } + return input +} diff --git a/internal/webserver/jobs/resources.go b/internal/webserver/jobs/resources.go new file mode 100644 index 00000000..df69b6ee --- /dev/null +++ b/internal/webserver/jobs/resources.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "errors" + "fmt" + "sync" + + "github.com/autobrr/upbrr/internal/logging" +) + +// resourceSet closes named per-job resources exactly once and retains the combined result. +type resourceSet struct { + once sync.Once + resources Resources + err error +} + +func (r *resourceSet) close() error { + if r == nil { + return nil + } + r.once.Do(func() { + if r.resources.Core != nil { + r.err = errors.Join(r.err, closeResource("core", r.resources.Core)) + } + if r.resources.Logger != nil { + r.err = errors.Join(r.err, closeResource("logger", r.resources.Logger)) + } + r.resources = Resources{} + }) + return r.err +} + +// closeResource converts close errors and panics into sanitized job diagnostics. +func closeResource(name string, resource Closer) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("%s close panicked: %s", name, sanitizeMessage(fmt.Sprint(recovered))) + } + }() + if err := resource.Close(); err != nil { + return fmt.Errorf("%s close failed: %s", name, sanitizeMessage(err.Error())) + } + return nil +} + +// sanitize removes secret and local-path detail before text enters a frontend-visible snapshot. +func sanitizeMessage(message string) string { + message = logging.SanitizeMessage(message) + if message == "" { + return "operation failed" + } + return message +} diff --git a/internal/webserver/jobs/retention_test.go b/internal/webserver/jobs/retention_test.go new file mode 100644 index 00000000..6f663915 --- /dev/null +++ b/internal/webserver/jobs/retention_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +type retentionTimer struct { + stopped atomic.Bool + callback func() +} + +func (owned *retentionTimer) Stop() bool { return !owned.stopped.Swap(true) } + +func (owned *retentionTimer) fire() { + if !owned.stopped.Load() && owned.callback != nil { + owned.callback() + } +} + +func TestRetentionPrunesOldestTerminalJobsAcrossKindsPerOwner(t *testing.T) { + t.Parallel() + var sequence atomic.Int64 + engine := newEngine(nil, Config{}, engineDeps{ + now: func() time.Time { return time.Unix(sequence.Add(1), 0) }, + newID: func() string { return fmt.Sprintf("job-%d", sequence.Add(1)) }, + retentionTTL: -1, + retentionMax: 2, + }) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + uploadRunner := uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { return api.Result{}, nil }) + dupeRunner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil + }) + + first, err := engine.StartUpload(context.Background(), owner, uploadSpec(uploadRunner, "A")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + _ = waitUpload(t, engine, first) + second, err := engine.StartDupe(context.Background(), owner, dupeSpec(dupeRunner, "A")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + _ = waitDupe(t, engine, second) + third, err := engine.StartUpload(context.Background(), owner, uploadSpec(uploadRunner, "B")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + _ = waitUpload(t, engine, third) + + if _, err := engine.UploadSnapshot(owner, first); !errors.Is(err, ErrUploadNotFound) { + t.Fatalf("oldest mixed-kind lookup error = %v", err) + } + if _, err := engine.DupeSnapshot(owner, second); err != nil { + t.Fatalf("retained dupe %s: %v", second, err) + } + if _, err := engine.UploadSnapshot(owner, third); err != nil { + t.Fatalf("retained upload %s: %v", third, err) + } + + other := mustRegisterOwner(t, engine, "other") + otherID, err := engine.StartUpload(context.Background(), other, uploadSpec(uploadRunner, "C")) + if err != nil { + t.Fatalf("StartUpload other: %v", err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + snapshot, snapshotErr := engine.UploadSnapshot(other, otherID) + if snapshotErr == nil && isJobTerminal(snapshot.Status) { + break + } + time.Sleep(time.Millisecond) + } + if _, err := engine.UploadSnapshot(other, otherID); err != nil { + t.Fatalf("other owner retention affected: %v", err) + } +} + +func TestRetentionTTLDeletesTerminalJob(t *testing.T) { + t.Parallel() + var timerMu sync.Mutex + var created *retentionTimer + engine := newEngine(nil, Config{}, engineDeps{ + retentionTTL: time.Hour, + retentionMax: 200, + newTimer: func(_ time.Duration, callback func()) timer { + timer := &retentionTimer{callback: callback} + timerMu.Lock() + created = timer + timerMu.Unlock() + return timer + }, + }) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + runner := dupeRunnerFunc(func(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil + }) + id, err := engine.StartDupe(context.Background(), owner, dupeSpec(runner, "A")) + if err != nil { + t.Fatalf("StartDupe: %v", err) + } + _ = waitDupe(t, engine, id) + timerMu.Lock() + owned := created + timerMu.Unlock() + if owned == nil { + t.Fatal("retention timer was not created") + } + owned.fire() + if _, err := engine.DupeSnapshot(owner, id); !errors.Is(err, ErrDupeNotFound) { + t.Fatalf("TTL lookup error = %v", err) + } +} + +func TestCapPruningStopsEvictedTTLTimer(t *testing.T) { + t.Parallel() + var mu sync.Mutex + created := make([]*retentionTimer, 0, 2) + engine := newEngine(nil, Config{}, engineDeps{ + retentionTTL: time.Hour, + retentionMax: 1, + newTimer: func(_ time.Duration, callback func()) timer { + owned := &retentionTimer{callback: callback} + mu.Lock() + created = append(created, owned) + mu.Unlock() + return owned + }, + }) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + runner := uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { return api.Result{}, nil }) + for range 2 { + id, err := engine.StartUpload(context.Background(), owner, uploadSpec(runner, "A")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + _ = waitUpload(t, engine, id) + } + mu.Lock() + defer mu.Unlock() + if len(created) != 2 || !created[0].stopped.Load() || created[1].stopped.Load() { + t.Fatalf("timer state created=%d firstStopped=%v secondStopped=%v", len(created), created[0].stopped.Load(), created[1].stopped.Load()) + } +} diff --git a/internal/webserver/jobs/types.go b/internal/webserver/jobs/types.go new file mode 100644 index 00000000..f91439e6 --- /dev/null +++ b/internal/webserver/jobs/types.go @@ -0,0 +1,170 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +// Package jobs owns the WebUI background job lifecycle. +package jobs + +import "github.com/autobrr/upbrr/pkg/api" + +const ( + // KindDuplicateCheck identifies retained duplicate-check Jobs. + KindDuplicateCheck = "duplicate_check" + // KindTrackerUpload identifies retained tracker-upload Jobs. + KindTrackerUpload = "tracker_upload" +) + +const ( + // StatusQueued indicates that a job or tracker is accepted but has not started. + StatusQueued = "queued" + // StatusRunning indicates that work is in progress. + StatusRunning = "running" + // StatusCompleted indicates that all requested work succeeded. + StatusCompleted = "completed" + // StatusCompletedWithErrors indicates that work finished with at least one tracker failure. + StatusCompletedWithErrors = "completed_with_errors" + // StatusFailed indicates that the job engine or runner could not complete the job. + StatusFailed = "failed" + // StatusCanceled indicates that cancellation stopped the job or tracker. + StatusCanceled = "canceled" +) + +// DupeCheckTrackerState is the frontend-visible state for one tracker in a duplicate-check job. +type DupeCheckTrackerState struct { + // Tracker is the normalized uppercase tracker name. + Tracker string `json:"tracker"` + // Status is the tracker lifecycle state. + Status string `json:"status"` + // Message is sanitized progress or outcome detail suitable for WebUI event delivery. + Message string `json:"message"` + // Result is the latest duplicate-check result for the tracker. + Result api.DupeCheckResult `json:"result"` + // StartedAt is an RFC3339 timestamp, or empty when work has not started. + StartedAt string `json:"startedAt"` + // FinishedAt is an RFC3339 timestamp, or empty while work is active. + FinishedAt string `json:"finishedAt"` +} + +// DupeCheckSnapshot is an immutable frontend payload describing one duplicate-check job. +type DupeCheckSnapshot struct { + // JobID identifies the job within its owner scope. + JobID string `json:"jobID"` + // CorrelationID is the caller-generated opaque start identity. + CorrelationID string `json:"correlationID"` + // Release identifies the exact prepared generation accepted for this Job. + Release api.ReleaseRef `json:"release"` + // RuntimeGeneration identifies the exact config/resource generation accepted for this job. + RuntimeGeneration uint64 `json:"runtimeGeneration"` + // RequestedTrackers retains the exact accepted selection for server-side + // prerequisite validation and is never serialized to the browser. + RequestedTrackers []string `json:"-"` + // Status is the job lifecycle state. + Status string `json:"status"` + // Trackers lists requested trackers first, followed by any dynamically discovered trackers. + Trackers []DupeCheckTrackerState `json:"trackers"` + // CompletedCount counts trackers that reached a terminal state. + CompletedCount int `json:"completedCount"` + // TotalCount is the greatest known tracker total, including dynamically discovered trackers. + TotalCount int `json:"totalCount"` + // Summary contains the accumulated duplicate-check results. + Summary api.DupeCheckSummary `json:"summary"` + // Failure is the stable safe terminal failure, when one occurred. + Failure *api.OperationFailure `json:"failure,omitempty"` + // StartedAt is the job start time in RFC3339 format. + StartedAt string `json:"startedAt"` + // FinishedAt is an RFC3339 timestamp, or empty while the job is active. + FinishedAt string `json:"finishedAt"` +} + +// TrackerUploadTrackerState is the frontend-visible state for one tracker upload. +type TrackerUploadTrackerState struct { + // Tracker is the tracker name supplied by the caller. + Tracker string `json:"tracker"` + // Status is the tracker lifecycle state. + Status string `json:"status"` + // Task identifies the current upload task. + Task string `json:"task"` + // TaskStatus describes the current task outcome or progress state. + TaskStatus string `json:"taskStatus"` + // Message is sanitized progress detail suitable for WebUI event delivery. + Message string `json:"message"` + // CompletedPieces is the number of completed hashing or processing pieces. + CompletedPieces int `json:"completedPieces"` + // TotalPieces is the total number of hashing or processing pieces. + TotalPieces int `json:"totalPieces"` + // Percent is integer completion percentage. + Percent int `json:"percent"` + // HashRateMiB is the current hashing throughput in MiB/s. + HashRateMiB float64 `json:"hashRateMiB"` + // UploadedCount is the number of successful uploads attributed to this tracker. + UploadedCount int `json:"uploadedCount"` + // StartedAt is an RFC3339 timestamp, or empty before tracker work starts. + StartedAt string `json:"startedAt"` + // FinishedAt is an RFC3339 timestamp, or empty while tracker work is active. + FinishedAt string `json:"finishedAt"` +} + +// TrackerUploadSnapshot is an immutable frontend payload describing one tracker-upload job. +type TrackerUploadSnapshot struct { + // JobID identifies the job within its owner scope. + JobID string `json:"jobID"` + // CorrelationID is the caller-generated opaque start identity. + CorrelationID string `json:"correlationID"` + // RetryOf identifies the prior upload Job when this Job is a linked retry. + RetryOf string `json:"retryOf,omitempty"` + // Release identifies the exact prepared generation accepted for this Job. + Release api.ReleaseRef `json:"release"` + // RuntimeGeneration identifies the exact config/resource generation accepted for this job. + RuntimeGeneration uint64 `json:"runtimeGeneration"` + // Status is the job lifecycle state. + Status string `json:"status"` + // CurrentTask is the latest job-wide task label. + CurrentTask string `json:"currentTask"` + // CurrentTaskStatus is the latest job-wide task state. + CurrentTaskStatus string `json:"currentTaskStatus"` + // CurrentMessage is sanitized progress detail suitable for WebUI event delivery. + CurrentMessage string `json:"currentMessage"` + // CurrentCompletedPieces is the latest completed piece count. + CurrentCompletedPieces int `json:"currentCompletedPieces"` + // CurrentTotalPieces is the latest total piece count. + CurrentTotalPieces int `json:"currentTotalPieces"` + // CurrentPercent is the latest integer completion percentage. + CurrentPercent int `json:"currentPercent"` + // CurrentHashRateMiB is the latest hashing throughput in MiB/s. + CurrentHashRateMiB float64 `json:"currentHashRateMiB"` + // Trackers contains per-tracker states in request order. + Trackers []TrackerUploadTrackerState `json:"trackers"` + // FailedTrackers contains the tracker names eligible for retry. + FailedTrackers []string `json:"failedTrackers"` + // UploadedCount is the sum of positive per-tracker upload counts. + UploadedCount int `json:"uploadedCount"` + // Failure is the stable safe terminal failure, when one occurred. + Failure *api.OperationFailure `json:"failure,omitempty"` + // StartedAt is the job start time in RFC3339 format. + StartedAt string `json:"startedAt"` + // FinishedAt is an RFC3339 timestamp, or empty while the job is active. + FinishedAt string `json:"finishedAt"` +} + +// OwnerJobSnapshot is the discriminated owner-listing representation of one retained Job. +type OwnerJobSnapshot struct { + // Kind selects which kind-specific snapshot is present. + Kind string `json:"kind"` + // JobID identifies the retained job within its owner scope. + JobID string `json:"jobID"` + // CorrelationID reconciles an accepted job with its browser start command. + CorrelationID string `json:"correlationID"` + // RetryOf identifies the prior upload job when this is a linked retry. + RetryOf string `json:"retryOf,omitempty"` + // Release identifies the exact prepared generation accepted for the job. + Release api.ReleaseRef `json:"release"` + // Status is the job lifecycle state. + Status string `json:"status"` + // StartedAt is the acceptance time in RFC3339 format. + StartedAt string `json:"startedAt"` + // FinishedAt is an RFC3339 timestamp, or empty while the job is active. + FinishedAt string `json:"finishedAt"` + // Dupe is present only when Kind is KindDuplicateCheck. + Dupe *DupeCheckSnapshot `json:"dupe,omitempty"` + // Upload is present only when Kind is KindTrackerUpload. + Upload *TrackerUploadSnapshot `json:"upload,omitempty"` +} diff --git a/internal/webserver/jobs/upload.go b/internal/webserver/jobs/upload.go new file mode 100644 index 00000000..d193fe54 --- /dev/null +++ b/internal/webserver/jobs/upload.go @@ -0,0 +1,350 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +// uploadJob contains mutable state for one owner-scoped multi-tracker upload. +type uploadJob struct { + mu sync.Mutex + id, correlationID, retryOf string + release api.ReleaseRef + spec UploadSpec + trackers []string + states map[string]TrackerUploadTrackerState + failedTrackers []string + uploadedCount int + status, currentTask, currentTaskStatus, currentMessage string + failure, outcomeFailure *api.OperationFailure + outcomeStatus string + currentCompleted, currentTotal, currentPercent int + currentHashRateMiB float64 + startedAt, finishedAt, outcomeAt, lastSnapshotEmit time.Time + progress UploadProgressPolicy +} + +func newUploadJob(id string, spec UploadSpec, now time.Time, progress UploadProgressPolicy) *uploadJob { + trackers := spec.Snapshot.Input.Trackers + states := make(map[string]TrackerUploadTrackerState, len(trackers)) + for _, tracker := range trackers { + states[tracker] = TrackerUploadTrackerState{ + Tracker: tracker, + Status: StatusQueued, + Message: StatusQueued, + } + } + return &uploadJob{ + id: id, + correlationID: spec.CorrelationID, + retryOf: spec.RetryOf, + release: spec.Snapshot.Input.Release, + spec: spec, + trackers: append([]string(nil), trackers...), + states: states, + status: StatusQueued, + startedAt: now, + progress: progress, + } +} + +// runUpload executes one operation-wide Core call and derives Job state from typed per-tracker progress. +func (e *Engine) runUpload(ctx context.Context, job *uploadJob) { + defer func() { + if recovered := recover(); recovered != nil { + e.failUpload(job, "upload worker panicked: "+sanitizeMessage(fmt.Sprint(recovered))) + } + }() + job.mu.Lock() + job.status = StatusRunning + job.mu.Unlock() + e.emitUploadJob(job) + progressCtx := api.WithUploadProgressReporter(ctx, func(update api.UploadProgressUpdate) { e.applyUploadProgress(job, update) }) + result, runErr := job.spec.Runner.RunUpload(progressCtx, job.plan()) + job.mu.Lock() + job.uploadedCount = max(0, result.UploadedCount) + job.outcomeAt = e.deps.now().UTC() + terminalStatus := StatusCompleted + var terminalFailure *api.OperationFailure + switch { + case ctx.Err() != nil: + terminalStatus = StatusCanceled + job.cancelActiveLocked() + case runErr != nil && api.IsTrackerLocalUploadError(runErr): + terminalStatus = StatusCompletedWithErrors + terminalFailure = failureForError( + runErr, + api.OperationKindUploadExecute, + "One or more tracker uploads failed.", + api.OperationRecoveryRetry, + ) + job.failNamedTrackersLocked(api.TrackerLocalUploadFailureNames(runErr), terminalFailure.Message) + job.completeActiveTrackersLocked() + case runErr != nil: + terminalStatus = StatusFailed + terminalFailure = failureForError( + runErr, + api.OperationKindUploadExecute, + "Upload failed.", + api.OperationRecoveryRetry, + ) + job.failActiveTrackersLocked(terminalFailure.Message) + case len(job.failedTrackers) > 0: + terminalStatus = StatusCompletedWithErrors + terminalFailure = genericFailure( + api.OperationKindUploadExecute, + "One or more tracker uploads failed.", + api.OperationRecoveryRetry, + ) + default: + job.completeActiveTrackersLocked() + } + job.outcomeStatus = terminalStatus + job.outcomeFailure = terminalFailure + job.mu.Unlock() +} + +// failUpload converts a worker or cleanup failure into a sanitized failed snapshot without overwriting terminal tracker states. +func (e *Engine) failUpload(job *uploadJob, message string) { + e.warn("tracker-upload Job failed: " + sanitizeMessage(message)) + now := e.deps.now().UTC() + job.mu.Lock() + if job.outcomeAt.IsZero() { + job.outcomeAt = now + } + job.outcomeStatus = StatusFailed + job.outcomeFailure = genericFailure( + api.OperationKindUploadExecute, + "Upload failed.", + api.OperationRecoveryRetry, + ) + for _, tracker := range job.trackers { + state := job.states[tracker] + if state.Status == StatusQueued || state.Status == StatusRunning { + state.Status = StatusFailed + state.Message = job.outcomeFailure.Message + state.FinishedAt = formatTime(job.outcomeAt) + job.states[tracker] = state + } + } + job.mu.Unlock() +} + +// finalizeUpload publishes the staged outcome only after per-Job resources close. +func (e *Engine) finalizeUpload(job *uploadJob) { + job.mu.Lock() + if job.outcomeStatus == "" { + job.outcomeStatus = StatusFailed + job.outcomeFailure = genericFailure( + api.OperationKindUploadExecute, + "Upload failed.", + api.OperationRecoveryRetry, + ) + } + job.status = job.outcomeStatus + job.failure = cloneFailure(job.outcomeFailure) + job.finishedAt = e.deps.now().UTC() + job.mu.Unlock() +} + +// applyUploadProgress updates job and tracker state for every event and applies only emission throttling. +func (e *Engine) applyUploadProgress(job *uploadJob, update api.UploadProgressUpdate) { + tracker := strings.TrimSpace(update.Tracker) + job.mu.Lock() + if tracker == "" && len(job.trackers) == 1 { + tracker = job.trackers[0] + } + now := e.deps.now().UTC() + previousStatus, previousPercent, previousRate := job.currentTaskStatus, job.currentPercent, job.currentHashRateMiB + job.currentTask = strings.TrimSpace(update.Task) + job.currentTaskStatus = strings.TrimSpace(update.Status) + job.currentMessage = strings.TrimSpace(update.Message) + if job.currentMessage != "" { + job.currentMessage = sanitizeMessage(job.currentMessage) + } + job.currentCompleted = update.CompletedPieces + job.currentTotal = update.TotalPieces + job.currentPercent = update.Percent + job.currentHashRateMiB = update.HashRateMiB + shouldEmit := job.progress.MinInterval <= 0 || + job.lastSnapshotEmit.IsZero() || + now.Sub(job.lastSnapshotEmit) >= job.progress.MinInterval || + previousPercent != job.currentPercent || + previousStatus != job.currentTaskStatus || + abs(previousRate-job.currentHashRateMiB) >= job.progress.HashRateDeltaMiB + if tracker != "" { + state := job.states[tracker] + state.Tracker = tracker + state.Task = job.currentTask + state.TaskStatus = job.currentTaskStatus + state.CompletedPieces = update.CompletedPieces + state.TotalPieces = update.TotalPieces + state.Percent = update.Percent + state.HashRateMiB = update.HashRateMiB + if job.currentMessage != "" { + state.Message = job.currentMessage + } + if state.Status == StatusQueued && strings.EqualFold(state.TaskStatus, StatusRunning) { + state.Status = StatusRunning + } + if state.StartedAt == "" && state.Status == StatusRunning { + state.StartedAt = formatTime(now) + } + terminalTask := strings.EqualFold(state.Task, "tracker_upload") || strings.EqualFold(state.Task, "tracker_preparation") + if terminalTask { + switch strings.ToLower(state.TaskStatus) { + case "completed": + if strings.EqualFold(state.Task, "tracker_upload") { + state.Status = "success" + state.UploadedCount = max(1, state.UploadedCount) + state.FinishedAt = formatTime(now) + } + case StatusFailed: + state.Status = StatusFailed + state.FinishedAt = formatTime(now) + job.addFailedTrackerLocked(tracker) + case StatusCanceled: + state.Status = StatusCanceled + state.FinishedAt = formatTime(now) + } + } + job.states[tracker] = state + } + if shouldEmit { + job.lastSnapshotEmit = now + } + job.mu.Unlock() + if shouldEmit { + e.emitUploadJob(job) + } +} + +func (j *uploadJob) plan() api.UploadExecutionPlan { + snapshot := cloneUploadExecutionSnapshot(j.spec.Snapshot) + snapshot.Input.Trackers = append([]string(nil), j.trackers...) + return api.UploadExecutionPlan{Input: snapshot.Input, Outcome: snapshot.Outcome} +} + +func (j *uploadJob) addFailedTrackerLocked(tracker string) { + for _, existing := range j.failedTrackers { + if strings.EqualFold(existing, tracker) { + return + } + } + j.failedTrackers = append(j.failedTrackers, tracker) +} + +func (j *uploadJob) failActiveTrackersLocked(message string) { + for _, tracker := range j.trackers { + state := j.states[tracker] + if state.Status == StatusQueued || state.Status == StatusRunning { + state.Status = StatusFailed + state.Message = message + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + j.addFailedTrackerLocked(tracker) + } + } +} + +func (j *uploadJob) failNamedTrackersLocked(trackers []string, message string) { + for _, tracker := range trackers { + state, ok := j.states[tracker] + if !ok || state.Status == "success" || state.Status == StatusCanceled { + continue + } + state.Status = StatusFailed + state.Message = message + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + j.addFailedTrackerLocked(tracker) + } +} + +func (j *uploadJob) completeActiveTrackersLocked() { + for _, tracker := range j.trackers { + state := j.states[tracker] + if state.Status == StatusQueued || state.Status == StatusRunning { + state.Status = "success" + state.Message = "uploaded" + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + } + } +} + +// snapshot copies all mutable upload state for lock-free sink delivery. +func (j *uploadJob) snapshot() TrackerUploadSnapshot { + j.mu.Lock() + defer j.mu.Unlock() + trackers := make([]TrackerUploadTrackerState, 0, len(j.trackers)) + for _, tracker := range j.trackers { + trackers = append(trackers, j.states[tracker]) + } + return TrackerUploadSnapshot{ + JobID: j.id, + CorrelationID: j.correlationID, + RetryOf: j.retryOf, + Release: j.release, + RuntimeGeneration: j.spec.Snapshot.RuntimeGeneration, + Status: j.status, + CurrentTask: j.currentTask, + CurrentTaskStatus: j.currentTaskStatus, + CurrentMessage: j.currentMessage, + CurrentCompletedPieces: j.currentCompleted, + CurrentTotalPieces: j.currentTotal, + CurrentPercent: j.currentPercent, + CurrentHashRateMiB: j.currentHashRateMiB, + Trackers: trackers, + FailedTrackers: append([]string{}, j.failedTrackers...), + UploadedCount: j.uploadedCount, + Failure: cloneFailure(j.failure), + StartedAt: formatTime(j.startedAt), + FinishedAt: formatTime(j.finishedAt), + } +} + +// retry copies immutable job input and restricts the tracker list to failed trackers. +func (j *uploadJob) retry() (UploadRetry, error) { + j.mu.Lock() + defer j.mu.Unlock() + if len(j.failedTrackers) == 0 { + return UploadRetry{}, errors.New("no failed trackers to retry") + } + return UploadRetry{ + RetryOf: j.id, + Snapshot: func() UploadExecutionSnapshot { + snapshot := cloneUploadExecutionSnapshot(j.spec.Snapshot) + snapshot.Input.Trackers = append([]string(nil), j.failedTrackers...) + return snapshot + }(), + }, nil +} + +func (j *uploadJob) cancelActiveLocked() { + for _, tracker := range j.trackers { + state := j.states[tracker] + if state.Status == StatusQueued || state.Status == StatusRunning { + state.Status = StatusCanceled + state.Message = StatusCanceled + state.FinishedAt = formatTime(j.outcomeAt) + j.states[tracker] = state + } + } +} + +func abs(value float64) float64 { + if value < 0 { + return -value + } + return value +} diff --git a/internal/webserver/jobs/upload_test.go b/internal/webserver/jobs/upload_test.go new file mode 100644 index 00000000..baf8cb69 --- /dev/null +++ b/internal/webserver/jobs/upload_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package jobs + +import ( + "context" + "slices" + "strings" + "sync" + "testing" + + "github.com/autobrr/upbrr/pkg/api" +) + +type trackerLocalUploadTestError struct{ message string } + +func (e trackerLocalUploadTestError) Error() string { return e.message } + +func (trackerLocalUploadTestError) TrackerLocalUploadFailures() []string { return []string{"A"} } + +func TestUploadPartialFailureContinuesAndBuildsClonedRetry(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var requests []api.UploadExecutionPlan + runner := uploadRunnerFunc(func(_ context.Context, request api.UploadExecutionPlan) (api.Result, error) { + mu.Lock() + requests = append(requests, request) + mu.Unlock() + return api.Result{UploadedCount: 2}, trackerLocalUploadTestError{message: "token=secret-value failed"} + }) + answers := map[string]map[string]string{"A": {"edition": "Example"}} + ignore := []string{"C"} + authorizations := []api.RuleAuthorization{{Tracker: "C", Rules: []string{"source_rule"}}} + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + spec := uploadSpec(runner, "A", "B") + spec.Snapshot.Input.QuestionnaireAnswers = answers + spec.Snapshot.Input.IgnoreDupesFor = ignore + spec.Snapshot.Input.RuleAuthorizations = authorizations + owner := mustRegisterOwner(t, engine, "owner") + id, err := engine.StartUpload(context.Background(), owner, spec) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + answers["A"]["edition"] = "mutated" + ignore[0] = "mutated" + authorizations[0].Rules[0] = "mutated" + snapshot := waitUpload(t, engine, id) + if snapshot.Status != StatusCompletedWithErrors || snapshot.UploadedCount != 2 { + t.Fatalf("terminal snapshot = %#v", snapshot) + } + if snapshot.Failure == nil || strings.Contains(snapshot.Failure.Message, "secret-value") { + t.Fatalf("unsafe or missing snapshot failure: %#v", snapshot.Failure) + } + if snapshot.Trackers[1].UploadedCount != 0 || snapshot.Trackers[1].Status != "success" { + t.Fatalf("second tracker = %#v", snapshot.Trackers[1]) + } + retry, err := engine.UploadRetry(owner, id) + if err != nil { + t.Fatalf("UploadRetry: %v", err) + } + if len(retry.Snapshot.Input.Trackers) != 1 || retry.Snapshot.Input.Trackers[0] != "A" || retry.Snapshot.Input.QuestionnaireAnswers["A"]["edition"] != "Example" || retry.Snapshot.Input.IgnoreDupesFor[0] != "C" || retry.Snapshot.Input.RuleAuthorizations[0].Rules[0] != "source_rule" { + t.Fatalf("retry = %#v", retry) + } + retry.Snapshot.Input.Trackers[0] = "mutated" + retry2, _ := engine.UploadRetry(owner, id) + if retry2.Snapshot.Input.Trackers[0] != "A" { + t.Fatal("retry exposes mutable retained trackers") + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 1 || + !slices.Equal(requests[0].Input.Trackers, []string{"A", "B"}) || + requests[0].Input.QuestionnaireAnswers["A"]["edition"] != "Example" || + requests[0].Input.IgnoreDupesFor[0] != "C" || + requests[0].Input.RuleAuthorizations[0].Rules[0] != "source_rule" { + t.Fatalf("requests = %#v", requests) + } +} + +func TestUploadCancellationPreservesPartialCountAndTerminalStates(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + close(started) + <-ctx.Done() + return api.Result{UploadedCount: 1}, ctx.Err() + }) + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + owner := mustRegisterOwner(t, engine, "owner") + id, err := engine.StartUpload(context.Background(), owner, uploadSpec(runner, "A", "B")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + <-started + if err := engine.CancelUpload(owner, id); err != nil { + t.Fatalf("CancelUpload: %v", err) + } + snapshot := waitUpload(t, engine, id) + if snapshot.Status != StatusCanceled || snapshot.UploadedCount != 1 { + t.Fatalf("snapshot = %#v", snapshot) + } + for _, state := range snapshot.Trackers { + if state.Status == StatusQueued || state.Status == StatusRunning { + t.Fatalf("active terminal tracker = %#v", state) + } + } +} + +func TestUploadProgressThrottleAndTrackerTargeting(t *testing.T) { + t.Parallel() + sink := &recordingSink{} + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + api.EmitUploadProgress(ctx, api.UploadProgressUpdate{ + Tracker: "A", + Task: "hash", + Status: "running", + Percent: 10, + HashRateMiB: 1, + }) + api.EmitUploadProgress(ctx, api.UploadProgressUpdate{ + Tracker: "A", + Task: "hash", + Status: "running", + Percent: 10, + HashRateMiB: 1.1, + }) + api.EmitUploadProgress(ctx, api.UploadProgressUpdate{ + Tracker: "A", + Task: "hash", + Status: "running", + Percent: 11, + HashRateMiB: 1.1, + }) + return api.Result{}, nil + }) + engine := New(sink, Config{UploadProgress: UploadProgressPolicy{MinInterval: 60_000_000_000, HashRateDeltaMiB: 1}}) + t.Cleanup(engine.Close) + id, err := engine.StartUpload(context.Background(), mustRegisterOwner(t, engine, "owner"), uploadSpec(runner, "A")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + snapshot := waitUpload(t, engine, id) + if snapshot.Trackers[0].Percent != 11 || snapshot.CurrentPercent != 11 { + t.Fatalf("progress state = %#v", snapshot) + } + if snapshot.FailedTrackers == nil { + t.Fatal("successful upload snapshot exposed null failedTrackers") + } + sink.mu.Lock() + defer sink.mu.Unlock() + // queued, running, first progress, percent change, terminal. + if len(sink.uploads) != 5 { + t.Fatalf("emission count = %d", len(sink.uploads)) + } +} + +func TestUploadProgressMessageIsSanitizedBeforeSnapshotDelivery(t *testing.T) { + t.Parallel() + sink := &recordingSink{} + runner := uploadRunnerFunc(func(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + api.EmitUploadProgress(ctx, api.UploadProgressUpdate{ + Tracker: "A", + Task: "hash", + Status: "running", + Message: " token=secret-value failed ", + }) + return api.Result{}, nil + }) + engine := New(sink, Config{}) + t.Cleanup(engine.Close) + id, err := engine.StartUpload(context.Background(), mustRegisterOwner(t, engine, "owner"), uploadSpec(runner, "A")) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + snapshot := waitUpload(t, engine, id) + if strings.Contains(snapshot.CurrentMessage, "secret-value") { + t.Fatalf("secret crossed current progress field: %q", snapshot.CurrentMessage) + } + sink.mu.Lock() + defer sink.mu.Unlock() + for _, emitted := range sink.uploads { + if strings.Contains(emitted.CurrentMessage, "secret-value") { + t.Fatalf("secret crossed emitted current field: %q", emitted.CurrentMessage) + } + for _, tracker := range emitted.Trackers { + if strings.Contains(tracker.Message, "secret-value") { + t.Fatalf("secret crossed emitted tracker field: %q", tracker.Message) + } + } + } +} + +func TestUploadPanicFailsButCloseFailurePreservesOutcome(t *testing.T) { + t.Parallel() + tests := []struct { + name string + runner UploadRunner + closer *closeRecorder + status string + }{ + { + name: "worker panic", + runner: uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + panic("token=secret-value") + }), + closer: &closeRecorder{}, + status: StatusFailed, + }, + { + name: "close panic", + runner: uploadRunnerFunc(func(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{UploadedCount: 1}, nil + }), + closer: &closeRecorder{panicValue: "password=secret-value"}, + status: StatusCompleted, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + engine := New(nil, Config{}) + t.Cleanup(engine.Close) + spec := uploadSpec(test.runner, "A") + spec.Resources.Core = test.closer + id, err := engine.StartUpload(context.Background(), mustRegisterOwner(t, engine, "owner"), spec) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + snapshot := waitUpload(t, engine, id) + if snapshot.Status != test.status || (snapshot.Failure != nil && strings.Contains(snapshot.Failure.Message, "secret-value")) { + t.Fatalf("snapshot = %#v", snapshot) + } + if test.closer.count.Load() != 1 { + t.Fatalf("close count = %d", test.closer.count.Load()) + } + }) + } +} diff --git a/internal/webserver/jobs_test.go b/internal/webserver/jobs_test.go index 742193f3..19008370 100644 --- a/internal/webserver/jobs_test.go +++ b/internal/webserver/jobs_test.go @@ -7,1093 +7,522 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" - "reflect" "strings" - "sync" "testing" "time" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/guishared" - "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/internal/preparedrelease" + sharedjobs "github.com/autobrr/upbrr/internal/webserver/jobs" "github.com/autobrr/upbrr/pkg/api" ) -type preparedMetaTestCore struct { - exportedMeta api.PreparedMetadata - exportFound bool - exportErr error - exportStarted chan struct{} - exportContinue chan struct{} - exportStartedOnce sync.Once - expectedExportReq *api.Request - importedMeta api.PreparedMetadata - importedReq api.Request - fetchReq api.Request - dupeSummary api.DupeCheckSummary - dupeCalls int - dvdMenuCapture func(context.Context, api.Request) (api.DVDMenuCaptureResult, error) - dvdMenuCalls int - closeCalls int - uploads []uploadPreparedResponse - uploadCalls int -} - -type uploadPreparedResponse struct { - result api.Result - err error - beforeReturn func() +type adapterTestCore struct { + uploadRequest api.Request + uploadInput api.UploadReviewInput + uploadResult api.Result + uploadErr error + dupeRequest api.Request + dupeInput api.DuplicateCheckInput + dupeSummary api.DupeCheckSummary + dupeErr error + exportErr error } -func (c *preparedMetaTestCore) RunUpload(context.Context, api.Request) (api.Result, error) { - return api.Result{}, nil +type preparedMetaTestCore struct { + fetchReq api.Request + prepareInput api.PrepareInput + closeCalls int +} + +func webTestUploadSnapshot(sourcePath string) sharedjobs.UploadExecutionSnapshot { + return sharedjobs.UploadExecutionSnapshot{ + PreparedGeneration: 1, + RuntimeGeneration: 1, + Input: api.UploadReviewInput{ + Release: api.ReleaseRef{SourcePath: sourcePath, Generation: 1}, + Trackers: []string{"EXAMPLE"}, + }, + ReviewToken: "test-review-token", + } } -func (c *preparedMetaTestCore) RunUploadPrepared(context.Context, api.Request) (api.Result, error) { - if c.uploadCalls < len(c.uploads) { - response := c.uploads[c.uploadCalls] - c.uploadCalls++ - if response.beforeReturn != nil { - response.beforeReturn() - } - return response.result, response.err +func webTestDupeSnapshot(sourcePath string) sharedjobs.DuplicateExecutionSnapshot { + return sharedjobs.DuplicateExecutionSnapshot{ + PreparedGeneration: 1, + RuntimeGeneration: 1, + Input: api.DuplicateCheckInput{ + Release: api.ReleaseRef{SourcePath: sourcePath, Generation: 1}, + Trackers: []string{"EXAMPLE"}, + }, } - return api.Result{}, nil } -func (c *preparedMetaTestCore) FetchMetadataPreview(_ context.Context, req api.Request) (api.MetadataPreview, error) { - c.fetchReq = req +func (c *preparedMetaTestCore) FetchMetadataPreview(_ context.Context, request api.Request) (api.MetadataPreview, error) { + c.fetchReq = request return api.MetadataPreview{}, nil } -func (c *preparedMetaTestCore) FetchDescriptionBuilderPreview(context.Context, api.Request) (api.DescriptionBuilderPreview, error) { - return api.DescriptionBuilderPreview{}, nil -} - -func (c *preparedMetaTestCore) FetchPreparationPreview(context.Context, api.Request) (api.PreparationPreview, error) { - return api.PreparationPreview{}, nil -} - -func (c *preparedMetaTestCore) FetchTrackerDryRunPreview(context.Context, api.Request) (api.TrackerDryRunPreview, error) { - return api.TrackerDryRunPreview{}, nil -} - -func (c *preparedMetaTestCore) CheckDupes(context.Context, api.Request) (api.DupeCheckSummary, error) { - c.dupeCalls++ - return c.dupeSummary, nil -} - -func (c *preparedMetaTestCore) BuildUploadReview(context.Context, api.Request) (api.UploadReview, error) { - return api.UploadReview{}, nil +func (c *preparedMetaTestCore) FetchAcceptedMetadataPreview(_ context.Context, ref api.ReleaseRef) (api.MetadataPreview, error) { + return api.MetadataPreview{SourcePath: ref.SourcePath}, nil } -func (c *preparedMetaTestCore) FetchScreenshotPlan(context.Context, api.Request) (api.ScreenshotPlan, error) { - return api.ScreenshotPlan{}, nil +func (c *preparedMetaTestCore) PrepareRelease(_ context.Context, input api.PrepareInput) (api.PrepareResult, error) { + c.prepareInput = input + return api.PrepareResult{Release: api.PreparedRelease{Generation: 1, Source: api.SourceManifest{SourcePath: input.SourcePath}}}, nil } -func (c *preparedMetaTestCore) GenerateScreenshots(context.Context, api.Request, []api.ScreenshotSelection, api.ScreenshotPurpose) (api.ScreenshotResult, error) { - return api.ScreenshotResult{}, nil +func (*preparedMetaTestCore) ExportReleaseSeed(context.Context, api.ReleaseRef) (preparedrelease.Seed, error) { + return preparedrelease.Seed{}, nil } -func (c *preparedMetaTestCore) PreviewScreenshotFrame(context.Context, api.Request, float64) (api.ScreenshotPreview, error) { - return api.ScreenshotPreview{}, nil -} - -func (c *preparedMetaTestCore) DeleteScreenshot(context.Context, api.Request, string) error { - return nil +func (*preparedMetaTestCore) ImportReleaseSeed(context.Context, preparedrelease.Seed) (api.ReleaseRef, error) { + return api.ReleaseRef{}, nil } -func (c *preparedMetaTestCore) DeleteTrackerImageURL(context.Context, api.Request, string) error { - return nil -} - -func (c *preparedMetaTestCore) SaveFinalScreenshotSelections(context.Context, api.Request, []api.ScreenshotImage) error { - return nil +func (*preparedMetaTestCore) SelectBlurayCandidate(context.Context, string, string) (api.MetadataPreview, error) { + return api.MetadataPreview{}, nil } -func (c *preparedMetaTestCore) ImportMenuImages(context.Context, api.Request, []string) error { +func (c *preparedMetaTestCore) Close() error { + c.closeCalls++ return nil } -func (c *preparedMetaTestCore) CaptureDVDMenus(ctx context.Context, req api.Request) (api.DVDMenuCaptureResult, error) { - c.dvdMenuCalls++ - if c.dvdMenuCapture != nil { - return c.dvdMenuCapture(ctx, req) +func webTestCapabilities(svc any) CoreCapabilities { + return CoreCapabilities{ + Metadata: webCapabilityAs[MetadataCapability](svc), + ReleasePreparation: webCapabilityAs[ReleasePreparationCapability](svc), + Selection: webCapabilityAs[SelectionCapability](svc), + Preparation: webCapabilityAs[PreparationCapability](svc), + UploadReview: webCapabilityAs[UploadReviewCapability](svc), + DryRun: webCapabilityAs[DryRunCapability](svc), + DuplicateExecution: webCapabilityAs[DuplicateExecutionCapability](svc), + UploadExecution: webCapabilityAs[UploadExecutionCapability](svc), + Screenshots: webCapabilityAs[ScreenshotCapability](svc), + HostedImages: webCapabilityAs[HostedImageCapability](svc), + DVD: webCapabilityAs[DVDCapability](svc), + Description: webCapabilityAs[DescriptionCapability](svc), + Playlists: webCapabilityAs[PlaylistCapability](svc), + History: webCapabilityAs[HistoryCapability](svc), + PreparedGenerationTransfer: webCapabilityAs[PreparedGenerationTransfer](svc), + DiagnosticProbe: webCapabilityAs[DiagnosticProbeCapability](svc), } - return api.DVDMenuCaptureResult{}, nil } -func (c *preparedMetaTestCore) ListDVDMenuScreenshots(context.Context, api.Request) ([]api.ScreenshotImage, error) { - return nil, nil +func webCapabilityAs[T any](svc any) T { + capability, _ := svc.(T) + return capability } -func (c *preparedMetaTestCore) DeleteDVDMenuScreenshot(context.Context, api.Request, string) error { - return nil +func (c *adapterTestCore) RunUploadPrepared(_ context.Context, request api.Request) (api.Result, error) { + c.uploadRequest = request + return c.uploadResult, c.uploadErr } -func (c *preparedMetaTestCore) ListUploadCandidates(context.Context, api.Request) ([]api.ScreenshotImage, error) { - return nil, nil +func (c *adapterTestCore) RunAcceptedUpload(_ context.Context, plan api.UploadExecutionPlan) (api.Result, error) { + c.uploadInput = plan.Input + return c.uploadResult, c.uploadErr } -func (c *preparedMetaTestCore) ListUploadedImages(context.Context, api.Request) ([]api.UploadedImageLink, error) { - return nil, nil +func (c *adapterTestCore) CheckDupes(_ context.Context, request api.Request) (api.DupeCheckSummary, error) { + c.dupeRequest = request + return c.dupeSummary, c.dupeErr } -func (c *preparedMetaTestCore) UploadImages(context.Context, api.Request, string, []api.ScreenshotImage) (api.UploadImagesResult, error) { - return api.UploadImagesResult{}, nil +func (c *adapterTestCore) CheckAcceptedDupes(_ context.Context, input api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + c.dupeInput = input + return c.dupeSummary, c.dupeErr } -func (c *preparedMetaTestCore) DeleteUploadedImage(context.Context, api.Request, string, string) error { - return nil +func (*adapterTestCore) FetchPreparationPreview(context.Context, api.Request) (api.PreparationPreview, error) { + return api.PreparationPreview{}, nil } -func (c *preparedMetaTestCore) DiscoverPlaylists(context.Context, string) ([]api.PlaylistInfo, error) { - return nil, nil +func (*adapterTestCore) RunAcceptedTrackerDryRun(context.Context, api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) { + return api.TrackerDryRunPreview{}, nil } -func (c *preparedMetaTestCore) SavePlaylistSelection(context.Context, string, []string, bool) error { - return nil +func (*adapterTestCore) CaptureDVDMenus(context.Context, api.Request) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, nil } -func (c *preparedMetaTestCore) LoadPlaylistSelection(context.Context, string) (api.PlaylistSelection, error) { - return api.PlaylistSelection{}, nil +func (*adapterTestCore) CaptureAcceptedDVDMenus(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, nil } -func (c *preparedMetaTestCore) ListHistory(context.Context) ([]api.HistoryEntry, error) { +func (*adapterTestCore) ListDVDMenuScreenshots(context.Context, api.Request) ([]api.ScreenshotImage, error) { return nil, nil } -func (c *preparedMetaTestCore) GetHistoryOverview(context.Context, string) (api.HistoryOverview, error) { - return api.HistoryOverview{}, nil -} - -func (c *preparedMetaTestCore) DeleteHistoryRelease(context.Context, string) error { +func (*adapterTestCore) DeleteDVDMenuScreenshot(context.Context, api.Request, string) error { return nil } -func (c *preparedMetaTestCore) DeleteAllHistoryReleases(context.Context) (int, error) { - return 0, nil -} - -func (c *preparedMetaTestCore) RenderDescription(context.Context, string) (string, error) { - return "", nil -} - -func (c *preparedMetaTestCore) FetchDescriptionBuilderGroupPreview(context.Context, api.Request) (api.DescriptionBuilderGroup, error) { - return api.DescriptionBuilderGroup{}, nil +func (*adapterTestCore) BuildUploadReview(context.Context, api.Request) (api.UploadReview, error) { + return api.UploadReview{}, nil } -func (c *preparedMetaTestCore) SaveDescriptionOverride(context.Context, api.Request, string) (api.DescriptionBuilderGroup, error) { - return api.DescriptionBuilderGroup{}, nil +func (c *adapterTestCore) ReviewAcceptedUpload(context.Context, api.UploadReviewInput) (api.ReviewedUpload, error) { + return api.ReviewedUpload{}, c.exportErr } -func (c *preparedMetaTestCore) Close() error { - c.closeCalls++ - return nil +func (*adapterTestCore) PrepareRelease(_ context.Context, input api.PrepareInput) (api.PrepareResult, error) { + return api.PrepareResult{Release: api.PreparedRelease{Generation: 1, Source: api.SourceManifest{SourcePath: input.SourcePath}}}, nil } -func (c *preparedMetaTestCore) ExportGUICachedPreparedMeta(ctx context.Context, req api.Request) (api.PreparedMetadata, bool, error) { - if c.exportStarted != nil { - c.exportStartedOnce.Do(func() { - close(c.exportStarted) - }) - } - if err := assertPreparedMetaExportRequest(req, c.expectedExportReq); err != nil { - return api.PreparedMetadata{}, false, err - } - if c.exportContinue != nil { - select { - case <-ctx.Done(): - return api.PreparedMetadata{}, false, fmt.Errorf("export canceled: %w", ctx.Err()) - case <-c.exportContinue: - } - } - if err := ctx.Err(); err != nil { - return api.PreparedMetadata{}, false, fmt.Errorf("export canceled: %w", err) - } - return c.exportedMeta, c.exportFound, c.exportErr +func (*adapterTestCore) ExportReleaseSeed(context.Context, api.ReleaseRef) (preparedrelease.Seed, error) { + return preparedrelease.Seed{}, nil } -func (c *preparedMetaTestCore) ImportPreparedMetadataForGUI(_ context.Context, req api.Request, meta api.PreparedMetadata) error { - c.importedReq = req - c.importedMeta = meta - return nil +func (*adapterTestCore) ImportReleaseSeed(context.Context, preparedrelease.Seed) (api.ReleaseRef, error) { + return api.ReleaseRef{}, nil } -func TestPruneCompletedDupeJobsLockedKeepsNewestCompleted(t *testing.T) { - backend := &Backend{ - dupes: make(map[string]*dupeCheckJob), - } - - active := &dupeCheckJob{id: "active", status: "running"} - backend.dupes[active.id] = active - - now := time.Now().UTC() - for idx := range 3 { - id := fmt.Sprintf("dupe-%d", idx) - backend.dupes[id] = &dupeCheckJob{ - id: id, - status: "completed", - finishedAt: now.Add(time.Duration(idx) * time.Minute), - } - } - - backend.pruneCompletedDupeJobsLocked(2) - - if _, ok := backend.dupes["dupe-0"]; ok { - t.Fatal("expected oldest completed dupe job to be pruned") - } - if _, ok := backend.dupes["dupe-1"]; !ok { - t.Fatal("expected newer completed dupe job to remain") - } - if _, ok := backend.dupes["dupe-2"]; !ok { - t.Fatal("expected newest completed dupe job to remain") - } - if _, ok := backend.dupes[active.id]; !ok { - t.Fatal("expected active dupe job to remain") - } -} - -func TestStartDupeCheckRequiresMetadataPreviewCache(t *testing.T) { +func TestWebUploadRunnerUsesPreparedUploadAndWebPrefix(t *testing.T) { t.Parallel() - coreSvc := &preparedMetaTestCore{} - backend := &Backend{ - core: coreSvc, - dupes: make(map[string]*dupeCheckJob), - } - - _, err := backend.StartDupeCheck(context.Background(), "session", `C:\Media\Example.mkv`, api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, []string{"AITHER"}) - if err == nil { - t.Fatal("expected missing metadata preview cache to block dupe check start") - } - if !strings.Contains(err.Error(), "dupe check requires metadata preview") { - t.Fatalf("expected metadata preview error, got %v", err) + coreSvc := &adapterTestCore{uploadErr: errors.New("upload failed")} + request := api.Request{SourcePath: "Example.Release.2026.1080p-GRP", Trackers: []string{"EXAMPLE"}} + _, err := (webUploadRunner{ + core: coreSvc, generationTarget: coreSvc, + }).RunUpload(context.Background(), api.UploadExecutionPlan{Input: api.UploadReviewInput{ + Release: api.ReleaseRef{SourcePath: request.SourcePath}, + Trackers: request.Trackers, + }, + }) + if err == nil || !strings.HasPrefix(err.Error(), "web: ") { + t.Fatalf("expected web-prefixed upload error, got %v", err) } - if count := dupeJobCount(backend); count != 0 { - t.Fatalf("expected no durable dupe job after cache miss, got %d", count) + if coreSvc.uploadInput.Release.SourcePath != request.SourcePath || coreSvc.uploadInput.Trackers[0] != request.Trackers[0] { + t.Fatalf("prepared upload input mismatch: %#v", coreSvc.uploadInput) } } -func TestStartDupeCheckUsesMetadataPreviewCache(t *testing.T) { +func TestWebDupeRunnerPreservesRequest(t *testing.T) { t.Parallel() - backend, sourcePath := newDupeCheckStartTestBackend(t) - tmdbID := 1234567 - releaseType := "movie" - overrides := api.ExternalIDOverrides{TMDBID: &tmdbID} - nameOverrides := api.ReleaseNameOverrides{Type: &releaseType} - coreSvc := &preparedMetaTestCore{ - exportFound: true, - exportedMeta: api.PreparedMetadata{SourcePath: sourcePath}, - expectedExportReq: &api.Request{ - Paths: []string{sourcePath}, - Mode: api.ModeGUI, - Trackers: []string{"AITHER"}, - ExternalIDOverrides: overrides, - ReleaseNameOverrides: nameOverrides, - }, - } - backend.core = coreSvc - - jobID, err := backend.StartDupeCheck(context.Background(), "session", sourcePath, overrides, nameOverrides, []string{"AITHER"}) + coreSvc := &adapterTestCore{dupeSummary: api.DupeCheckSummary{SourcePath: "Example.Release.2026.1080p-GRP"}} + request := api.Request{ + SourcePath: "Example.Release.2026.1080p-GRP", + Trackers: []string{"EXAMPLE"}, + ExternalIDOverrides: api.ExternalIDOverrides{}, + ReleaseNameOverrides: api.ReleaseNameOverrides{}, + } + summary, err := (webDupeRunner{ + core: coreSvc, generationTarget: coreSvc, + }).CheckDupes(context.Background(), api.DuplicateCheckInput{ + Release: api.ReleaseRef{SourcePath: request.SourcePath}, + Trackers: request.Trackers, + }) if err != nil { - t.Fatalf("start dupe check: %v", err) - } - if strings.TrimSpace(jobID) == "" { - t.Fatal("expected job id") + t.Fatalf("check dupes: %v", err) } - if count := dupeJobCount(backend); count != 1 { - t.Fatalf("expected durable dupe job, got %d", count) - } -} - -func TestStartDupeCheckFailsOnMetadataPreviewCacheRequestMismatch(t *testing.T) { - t.Parallel() - - for _, tt := range []struct { - name string - expected func(string) api.Request - overrides api.ExternalIDOverrides - wantMessage string - }{ - { - name: "path", - expected: func(sourcePath string) api.Request { - return api.Request{Paths: []string{sourcePath + ".other"}, Mode: api.ModeGUI, Trackers: []string{"AITHER"}} - }, - wantMessage: "metadata preview cache request paths", - }, - { - name: "tracker", - expected: func(sourcePath string) api.Request { - return api.Request{Paths: []string{sourcePath}, Mode: api.ModeGUI, Trackers: []string{"BLU"}} - }, - wantMessage: "metadata preview cache request trackers", - }, - { - name: "external id override", - expected: func(sourcePath string) api.Request { - tmdbID := 7654321 - return api.Request{ - Paths: []string{sourcePath}, - Mode: api.ModeGUI, - Trackers: []string{"AITHER"}, - ExternalIDOverrides: api.ExternalIDOverrides{TMDBID: &tmdbID}, - } - }, - overrides: func() api.ExternalIDOverrides { - tmdbID := 1234567 - return api.ExternalIDOverrides{TMDBID: &tmdbID} - }(), - wantMessage: "metadata preview cache request external overrides", - }, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - backend, sourcePath := newDupeCheckStartTestBackend(t) - expected := tt.expected(sourcePath) - coreSvc := &preparedMetaTestCore{ - exportFound: true, - exportedMeta: api.PreparedMetadata{SourcePath: sourcePath}, - expectedExportReq: &expected, - } - backend.core = coreSvc - - _, err := backend.StartDupeCheck(context.Background(), "session", sourcePath, tt.overrides, api.ReleaseNameOverrides{}, []string{"AITHER"}) - if err == nil { - t.Fatal("expected metadata preview cache request mismatch to fail") - } - if !strings.Contains(err.Error(), tt.wantMessage) { - t.Fatalf("expected mismatch error %q, got %v", tt.wantMessage, err) - } - if count := dupeJobCount(backend); count != 0 { - t.Fatalf("expected no durable dupe job after cache request mismatch, got %d", count) - } - }) + if summary.SourcePath != coreSvc.dupeInput.Release.SourcePath { + t.Fatalf("dupe input mismatch: %#v", coreSvc.dupeInput) } } -func TestStartDupeCheckStopsPreJobExportOnRequestCancel(t *testing.T) { +func TestStartTrackerUploadRejectsPartialCapabilityBundle(t *testing.T) { t.Parallel() - backend, sourcePath := newDupeCheckStartTestBackend(t) - coreSvc := &preparedMetaTestCore{ - exportFound: true, - exportedMeta: api.PreparedMetadata{SourcePath: sourcePath}, - exportStarted: make(chan struct{}), - exportContinue: make(chan struct{}), - } - backend.core = coreSvc - - ctx, cancel := context.WithCancel(context.Background()) - errCh := make(chan error, 1) - go func() { - _, err := backend.StartDupeCheck(ctx, "session", sourcePath, api.ExternalIDOverrides{}, api.ReleaseNameOverrides{}, []string{"AITHER"}) - errCh <- err - }() - - select { - case <-coreSvc.exportStarted: - case <-time.After(250 * time.Millisecond): - t.Fatal("expected metadata cache export to start") - } - cancel() - - select { - case err := <-errCh: - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected canceled export error, got %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("expected start to return after request cancellation") - } - if count := dupeJobCount(backend); count != 0 { - t.Fatalf("expected no durable dupe job after canceled export, got %d", count) - } -} - -func TestPruneCompletedUploadJobsLockedKeepsNewestCompleted(t *testing.T) { backend := &Backend{ - uploads: make(map[string]*trackerUploadJob), - } - - active := &trackerUploadJob{id: "active", status: "running"} - backend.uploads[active.id] = active + capabilities: CoreCapabilities{UploadExecution: &adapterTestCore{}}, + } + _, err := backend.ReviewTrackerUpload( + context.Background(), + "session-1", + api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 1}, + []string{"EXAMPLE"}, + nil, + nil, + nil, + nil, + false, + "", + ) + if !errors.Is(err, ErrPreparedGenerationUnavailable) { + t.Fatalf("expected missing transfer capability error, got %v", err) + } +} + +func TestWebStartTrackerUploadExportsBeforePerRunSetup(t *testing.T) { + t.Parallel() - now := time.Now().UTC() - for idx := range 3 { - id := fmt.Sprintf("upload-%d", idx) - backend.uploads[id] = &trackerUploadJob{ - id: id, - status: "completed", - finishedAt: now.Add(time.Duration(idx) * time.Minute), + exportErr := errors.New("prepared authorization rejected") + coreSvc := &adapterTestCore{exportErr: exportErr} + backend := &Backend{capabilities: webTestCapabilities(coreSvc)} + t.Cleanup(func() { + if backend.jobEngine != nil { + backend.jobEngine.Close() } - } - - backend.pruneCompletedUploadJobsLocked(2) + }) + _, err := backend.ReviewTrackerUpload( + context.Background(), + "session-1", + api.ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 1}, + []string{"EXAMPLE"}, + nil, + nil, + nil, + nil, + false, + "", + ) + if !errors.Is(err, exportErr) { + t.Fatalf("expected export error before per-run setup, got %v", err) + } +} + +func TestWebDupeRunnerPreservesCoreErrorText(t *testing.T) { + t.Parallel() - if _, ok := backend.uploads["upload-0"]; ok { - t.Fatal("expected oldest completed upload job to be pruned") - } - if _, ok := backend.uploads["upload-1"]; !ok { - t.Fatal("expected newer completed upload job to remain") - } - if _, ok := backend.uploads["upload-2"]; !ok { - t.Fatal("expected newest completed upload job to remain") - } - if _, ok := backend.uploads[active.id]; !ok { - t.Fatal("expected active upload job to remain") + want := "provider rejected duplicate check" + coreSvc := &adapterTestCore{dupeErr: errors.New(want)} + _, err := (webDupeRunner{core: coreSvc, generationTarget: coreSvc}).CheckDupes( + context.Background(), api.DuplicateCheckInput{}, + ) + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want exact %q", err, want) } } -func TestDupeJobAccessRequiresOwningSession(t *testing.T) { - backend := &Backend{ - dupes: map[string]*dupeCheckJob{ - "job-1": { - sessionID: "session-a", - id: "job-1", - sourcePath: `C:\Media\Movie.mkv`, - status: "running", - states: map[string]DupeCheckTrackerState{}, - startedAt: time.Now().UTC(), - }, - }, - } - - if _, err := backend.GetDupeCheckSnapshot("session-b", "job-1"); err == nil { - t.Fatal("expected foreign session snapshot read to be rejected") - } - if err := backend.CancelDupeCheck("session-b", "job-1"); err == nil { - t.Fatal("expected foreign session cancel to be rejected") - } - if _, err := backend.GetDupeCheckSnapshot("session-a", "job-1"); err != nil { - t.Fatalf("expected owning session snapshot read to succeed: %v", err) - } -} - -func TestTrackerUploadJobAccessRequiresOwningSession(t *testing.T) { - backend := &Backend{ - uploads: map[string]*trackerUploadJob{ - "job-1": { - sessionID: "session-a", - id: "job-1", - sourcePath: `C:\Media\Movie.mkv`, - status: "running", - states: map[string]TrackerUploadTrackerState{}, - startedAt: time.Now().UTC(), - }, - }, - } +func TestBuildRunOptionsKeepsDebugAsLogLevelOnly(t *testing.T) { + t.Parallel() - if _, err := backend.GetTrackerUploadSnapshot("session-b", "job-1"); err == nil { - t.Fatal("expected foreign session snapshot read to be rejected") - } - if err := backend.CancelTrackerUpload("session-b", "job-1"); err == nil { - t.Fatal("expected foreign session cancel to be rejected") - } - if _, err := backend.RetryFailedTrackerUpload("session-b", "job-1"); err == nil { - t.Fatal("expected foreign session retry to be rejected") + backend := &Backend{} + runOpts, err := backend.buildRunOptions(false, "debug") + if err != nil { + t.Fatalf("build run options: %v", err) } - if _, err := backend.GetTrackerUploadSnapshot("session-a", "job-1"); err != nil { - t.Fatalf("expected owning session snapshot read to succeed: %v", err) + options := buildRunUploadOptions(config.Config{}, runOpts) + if options.RunLogLevel != "debug" || options.NoSeed { + t.Fatalf("upload options = %#v", options) } } -func TestTrackerUploadRetryRequestUsesStoredUploadOptions(t *testing.T) { - job := &trackerUploadJob{ - sessionID: "session-a", - sourcePath: `C:\Media\Movie.mkv`, - uploadOptions: api.UploadOptions{Screens: 1, SkipAutoTorrent: true}, - runOptions: runOptions{Debug: true, NoSeed: true, RunLogLevel: "debug"}, - failedTrackers: []string{"BLU"}, - ignoreDupesFor: []string{"AITHER"}, - } - - retry, err := trackerUploadRetryRequestFromJob(job) - if err != nil { - t.Fatalf("retry request: %v", err) - } - - job.uploadOptions.Screens = 9 - job.failedTrackers[0] = "MUTATED" - job.ignoreDupesFor[0] = "MUTATED" +type successfulUploadRunner struct{} - if retry.sessionID != "session-a" { - t.Fatalf("expected retry session snapshot, got %q", retry.sessionID) - } - if retry.uploadOptions.Screens != 1 || !retry.uploadOptions.SkipAutoTorrent { - t.Fatalf("expected stored upload options snapshot, got %#v", retry.uploadOptions) - } - if len(retry.failedTrackers) != 1 || retry.failedTrackers[0] != "BLU" { - t.Fatalf("expected failed trackers snapshot, got %#v", retry.failedTrackers) - } - if len(retry.ignoreDupesFor) != 1 || retry.ignoreDupesFor[0] != "AITHER" { - t.Fatalf("expected ignore dupes snapshot, got %#v", retry.ignoreDupesFor) - } - if !retry.runOptions.Debug || !retry.runOptions.NoSeed || retry.runOptions.RunLogLevel != "debug" { - t.Fatalf("expected run options snapshot, got %#v", retry.runOptions) - } +func (successfulUploadRunner) RunUpload(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{UploadedCount: 1}, nil } -func TestRunTrackerUploadJobCountsPartialErrorAndContinues(t *testing.T) { - backend := &Backend{hub: newEventHub()} - coreSvc := &preparedMetaTestCore{uploads: []uploadPreparedResponse{ - { - result: api.Result{UploadedCount: 1}, - err: errors.New("tracker failed after upload"), - }, - { - result: api.Result{UploadedCount: 2}, - }, - }} - job := newTrackerUploadJobTestJob(coreSvc, []string{"BLU", "AITHER"}) +type successfulDupeRunner struct{} - backend.uploadWG.Add(1) - backend.runTrackerUploadJob(context.Background(), job) - - if got := job.uploadedCount; got != 3 { - t.Fatalf("expected total uploaded count 3, got %d", got) - } - if got := job.states["BLU"].UploadedCount; got != 1 { - t.Fatalf("expected failed tracker partial count 1, got %d", got) - } - if got := job.states["BLU"].Status; got != "failed" { - t.Fatalf("expected partial error tracker to remain failed, got %q", got) - } - if got := job.states["AITHER"].UploadedCount; got != 2 { - t.Fatalf("expected success tracker count 2, got %d", got) - } - if got := job.status; got != "completed_with_errors" { - t.Fatalf("expected completed_with_errors status, got %q", got) - } - if len(job.failedTrackers) != 1 || job.failedTrackers[0] != "BLU" { - t.Fatalf("expected failed tracker BLU, got %#v", job.failedTrackers) - } +func (successfulDupeRunner) CheckDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil } -func TestRunDupeCheckJobSplitsGroupedPathedResultsIntoTrackerStates(t *testing.T) { +func TestWebJobFacadesPreserveLegacyOwnershipErrors(t *testing.T) { t.Parallel() - coreSvc := &preparedMetaTestCore{dupeSummary: api.DupeCheckSummary{ - SourcePath: `C:\Media\Movie.mkv`, - Results: []api.DupeCheckResult{ - { - Tracker: "AITHER, BLU", - HasDupes: true, - Status: "completed", - Notes: []string{"pathed torrent match found; skipping dupe search"}, - }, - {Tracker: "RF", Status: "completed"}, - }, - }} - job := newDupeCheckJobTestJob("session-a", coreSvc.dupeSummary.SourcePath, []string{"AITHER", "BLU", "RF"}) - job.core = coreSvc - backend := &Backend{hub: newEventHub()} - backend.dupeWG.Add(1) - - backend.runDupeCheckJob(context.Background(), job) + backend := &Backend{jobEngine: sharedjobs.New(nil, sharedjobs.Config{})} + t.Cleanup(backend.jobEngine.Close) - if got := job.completedCount; got != 3 { - t.Fatalf("expected all trackers completed, got %d", got) + owner, err := backend.ensureJobOwner("owner-a") + if err != nil { + t.Fatalf("ensure owner: %v", err) } - if _, ok := job.states["AITHER, BLU"]; ok { - t.Fatal("did not expect grouped tracker state") + sourcePath := "Example.Release.2026.1080p-GRP" + dupeID, err := backend.jobEngine.StartDupe(context.Background(), owner, sharedjobs.DupeSpec{ + CorrelationID: "owner-dupe", + Snapshot: webTestDupeSnapshot(sourcePath), + Runner: successfulDupeRunner{}, + }) + if err != nil { + t.Fatalf("start dupe: %v", err) } - for _, tracker := range []string{"AITHER", "BLU"} { - state := job.states[tracker] - if got := state.Status; got != "completed" { - t.Fatalf("expected %s completed, got %q", tracker, got) + uploadID, err := backend.jobEngine.StartUpload(context.Background(), owner, sharedjobs.UploadSpec{ + CorrelationID: "owner-upload", + Snapshot: webTestUploadSnapshot(sourcePath), + Runner: successfulUploadRunner{}, + }) + if err != nil { + t.Fatalf("start upload: %v", err) + } + + assertExactError(t, func() error { + _, snapshotErr := backend.GetDupeCheckSnapshot("owner-b", dupeID) + return snapshotErr + }, "dupe job not found") + assertExactError(t, func() error { return backend.CancelDupeCheck("owner-b", dupeID) }, "dupe job not found") + assertExactError(t, func() error { + _, snapshotErr := backend.GetDupeCheckSnapshot("owner-a", "unknown") + return snapshotErr + }, "dupe job not found") + + assertExactError(t, func() error { + _, snapshotErr := backend.GetTrackerUploadSnapshot("owner-b", uploadID) + return snapshotErr + }, "upload job not found") + assertExactError(t, func() error { return backend.CancelTrackerUpload("owner-b", uploadID) }, "upload job not found") + assertExactError(t, func() error { + _, retryErr := backend.RetryFailedTrackerUpload("owner-b", uploadID, "foreign-retry") + return retryErr + }, "upload job not found") + assertExactError(t, func() error { + _, snapshotErr := backend.GetTrackerUploadSnapshot("owner-a", "unknown") + return snapshotErr + }, "upload job not found") + + deadline := time.Now().Add(time.Second) + for { + snapshot, snapshotErr := backend.GetTrackerUploadSnapshot("owner-a", uploadID) + if snapshotErr != nil { + t.Fatalf("get upload snapshot: %v", snapshotErr) + } + if snapshot.Status == sharedjobs.StatusCompleted { + break } - if got := state.Result.Tracker; got != tracker { - t.Fatalf("expected %s result tracker, got %q", tracker, got) + if time.Now().After(deadline) { + t.Fatalf("upload did not complete: %#v", snapshot) } + time.Sleep(time.Millisecond) } + assertExactError(t, func() error { + _, retryErr := backend.RetryFailedTrackerUpload("owner-a", uploadID, "owner-retry") + return retryErr + }, "no failed trackers to retry") } -func TestApplyDupeProgressCountsNewTrackersWithoutInflatingExplicitTotal(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - startTotal int - updateTotal int - wantTotal int - }{ - {name: "explicit total", startTotal: 2, updateTotal: 2, wantTotal: 2}, - {name: "total omitted", startTotal: 1, wantTotal: 2}, - {name: "explicit total lower than current", startTotal: 3, updateTotal: 2, wantTotal: 3}, - {name: "explicit total higher than current", startTotal: 2, updateTotal: 3, wantTotal: 3}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - job := newDupeCheckJobTestJob("session-a", `C:\Media\Example.Release.2026.1080p-GRP.mkv`, []string{"AITHER"}) - job.totalCount = tt.startTotal - backend := &Backend{hub: newEventHub()} - - backend.applyDupeProgress(job, api.DupeProgressUpdate{ - Tracker: "BLU", - Status: "running", - Total: tt.updateTotal, - }) - - if job.totalCount != tt.wantTotal { - t.Fatalf("expected total count %d, got %d", tt.wantTotal, job.totalCount) - } - if len(job.trackers) != 2 || job.trackers[1] != "BLU" { - t.Fatalf("expected new tracker to be appended once, got %#v", job.trackers) - } - if got := job.states["BLU"].Status; got != "running" { - t.Fatalf("expected BLU running state, got %q", got) - } - }) +func assertExactError(t *testing.T, call func() error, want string) { + t.Helper() + if err := call(); err == nil || err.Error() != want { + t.Fatalf("error = %v, want exact %q", err, want) } } -func TestRunDupeCheckJobUsesJobCoreSnapshot(t *testing.T) { +func TestWebJobSinkUsesSessionAndExistingTopics(t *testing.T) { t.Parallel() - jobCore := &preparedMetaTestCore{dupeSummary: api.DupeCheckSummary{ - SourcePath: `C:\Media\Movie.mkv`, - Results: []api.DupeCheckResult{{Tracker: "AITHER", Status: "completed"}}, - }} - currentCore := &preparedMetaTestCore{dupeSummary: api.DupeCheckSummary{ - SourcePath: `C:\Media\Other.mkv`, - Results: []api.DupeCheckResult{{Tracker: "AITHER", Status: "failed"}}, - }} - job := newDupeCheckJobTestJob("session-a", jobCore.dupeSummary.SourcePath, []string{"AITHER"}) - job.core = jobCore - job.uploadOptions = api.UploadOptions{Screens: 1} - backend := &Backend{core: currentCore, hub: newEventHub()} - backend.dupeWG.Add(1) - - backend.runDupeCheckJob(context.Background(), job) - - if got := jobCore.dupeCalls; got != 1 { - t.Fatalf("expected job-owned core to run once, got %d", got) - } - if got := currentCore.dupeCalls; got != 0 { - t.Fatalf("expected current runtime core not to run, got %d", got) - } - if got := job.states["AITHER"].Status; got != "completed" { - t.Fatalf("expected result from job-owned core, got %q", got) - } -} - -func TestRunTrackerUploadJobIgnoresNegativeUploadedCount(t *testing.T) { - backend := &Backend{hub: newEventHub()} - coreSvc := &preparedMetaTestCore{uploads: []uploadPreparedResponse{ - { - result: api.Result{UploadedCount: -1}, - err: errors.New("tracker failed after invalid count"), - }, - }} - job := newTrackerUploadJobTestJob(coreSvc, []string{"BLU"}) - - backend.uploadWG.Add(1) - backend.runTrackerUploadJob(context.Background(), job) - - if got := job.uploadedCount; got != 0 { - t.Fatalf("expected negative count to leave total unchanged, got %d", got) - } - if got := job.states["BLU"].UploadedCount; got != 0 { - t.Fatalf("expected negative count to leave tracker count unchanged, got %d", got) - } - if got := job.states["BLU"].Status; got != "failed" { - t.Fatalf("expected tracker error handling to remain failed, got %q", got) - } - if len(job.failedTrackers) != 1 || job.failedTrackers[0] != "BLU" { - t.Fatalf("expected failed tracker BLU, got %#v", job.failedTrackers) - } -} - -func TestRunTrackerUploadJobCountsCanceledPartialWithoutFailedTracker(t *testing.T) { - backend := &Backend{hub: newEventHub()} - ctx, cancel := context.WithCancel(context.Background()) - coreSvc := &preparedMetaTestCore{uploads: []uploadPreparedResponse{ - { - result: api.Result{UploadedCount: 1}, - err: context.Canceled, - beforeReturn: cancel, - }, - }} - job := newTrackerUploadJobTestJob(coreSvc, []string{"BLU"}) - - backend.uploadWG.Add(1) - backend.runTrackerUploadJob(ctx, job) - - if got := job.uploadedCount; got != 1 { - t.Fatalf("expected canceled partial count 1, got %d", got) - } - if got := job.states["BLU"].UploadedCount; got != 1 { - t.Fatalf("expected tracker partial count 1, got %d", got) - } - if got := job.states["BLU"].Status; got != "canceled" { - t.Fatalf("expected canceled tracker status, got %q", got) - } - if got := job.states["BLU"].Message; got != "canceled" { - t.Fatalf("expected canceled tracker message, got %q", got) - } - if got := job.states["BLU"].FinishedAt; got == "" { - t.Fatal("expected canceled tracker finished time") - } - assertSnapshotHasNoActiveTrackerStates(t, buildTrackerUploadSnapshot(job)) - if got := job.status; got != "canceled" { - t.Fatalf("expected canceled job status, got %q", got) - } - if len(job.failedTrackers) != 0 { - t.Fatalf("expected no failed trackers after cancellation, got %#v", job.failedTrackers) - } -} - -func TestRunTrackerUploadJobCancelsQueuedTrackersBeforeFirstUpload(t *testing.T) { - backend := &Backend{hub: newEventHub()} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - coreSvc := &preparedMetaTestCore{} - job := newTrackerUploadJobTestJob(coreSvc, []string{"BLU", "AITHER"}) + hub := newEventHub() + events, stop := hub.Subscribe("session-a") + defer stop() - backend.uploadWG.Add(1) - backend.runTrackerUploadJob(ctx, job) + sink := webJobSink{hub: hub} + sink.EmitDupe("session-a", sharedjobs.DupeCheckSnapshot{JobID: "dupe-1"}) + sink.EmitUpload("session-a", sharedjobs.TrackerUploadSnapshot{JobID: "upload-1"}) - if got := coreSvc.uploadCalls; got != 0 { - t.Fatalf("expected no upload calls after pre-start cancellation, got %d", got) - } - if got := job.status; got != "canceled" { - t.Fatalf("expected canceled job status, got %q", got) - } - for _, tracker := range job.trackers { - state := job.states[tracker] - if state.Status != "canceled" { - t.Fatalf("expected %s status canceled, got %q", tracker, state.Status) - } - if state.Message != "canceled" { - t.Fatalf("expected %s message canceled, got %q", tracker, state.Message) - } - if state.FinishedAt == "" { - t.Fatalf("expected %s finished time", tracker) + select { + case event := <-events: + if event.Name != "dupe:job:dupe-1" { + t.Fatalf("unexpected dupe topic %q", event.Name) } + case <-time.After(time.Second): + t.Fatal("expected dupe event on existing topic") } - assertSnapshotHasNoActiveTrackerStates(t, buildTrackerUploadSnapshot(job)) -} - -func TestRunTrackerUploadJobCancelsRunningAndQueuedTrackersWithoutOverwritingSuccess(t *testing.T) { - backend := &Backend{hub: newEventHub()} - ctx, cancel := context.WithCancel(context.Background()) - coreSvc := &preparedMetaTestCore{uploads: []uploadPreparedResponse{ - { - result: api.Result{UploadedCount: 1}, - }, - { - err: context.Canceled, - beforeReturn: cancel, - }, - }} - job := newTrackerUploadJobTestJob(coreSvc, []string{"BLU", "AITHER", "BHD"}) - - backend.uploadWG.Add(1) - backend.runTrackerUploadJob(ctx, job) - - if got := job.uploadedCount; got != 1 { - t.Fatalf("expected total uploaded count 1, got %d", got) - } - if got := job.states["BLU"].Status; got != "success" { - t.Fatalf("expected completed tracker success to remain unchanged, got %q", got) - } - if got := job.states["AITHER"].Status; got != "canceled" { - t.Fatalf("expected running tracker canceled, got %q", got) - } - if got := job.states["BHD"].Status; got != "canceled" { - t.Fatalf("expected queued tracker canceled, got %q", got) - } - assertSnapshotHasNoActiveTrackerStates(t, buildTrackerUploadSnapshot(job)) - if got := job.status; got != "canceled" { - t.Fatalf("expected canceled job status, got %q", got) + select { + case event := <-events: + if event.Name != "jobs:update" { + t.Fatalf("unexpected registry topic %q", event.Name) + } + case <-time.After(time.Second): + t.Fatal("expected dupe registry event") } - if len(job.failedTrackers) != 0 { - t.Fatalf("expected no failed trackers after cancellation, got %#v", job.failedTrackers) + select { + case event := <-events: + if event.Name != "upload:job:upload-1" { + t.Fatalf("unexpected upload topic %q", event.Name) + } + case <-time.After(time.Second): + t.Fatal("expected upload event on existing topic") } -} - -func TestApplyTrackerUploadProgressThrottlesSmallHashRateBurst(t *testing.T) { - hub := newEventHub() - ch, unsubscribe := hub.Subscribe("session") - defer unsubscribe() - backend := &Backend{hub: hub} - job := newTrackerUploadProgressTestJob() - job.lastSnapshotEmit = time.Now() - job.snapshotThrottle = time.Hour - job.currentTaskStatus = "running" - job.currentPercent = 40 - job.currentHashRateMiB = 10 - - backend.applyTrackerUploadProgress(job, api.UploadProgressUpdate{ - Tracker: "BLU", - Status: "running", - Percent: 40, - HashRateMiB: 10.25, - }) - select { - case event := <-ch: - t.Fatalf("expected throttled hash-rate-only update, got event %q", event.Name) - default: + case event := <-events: + if event.Name != "jobs:update" { + t.Fatalf("unexpected registry topic %q", event.Name) + } + case <-time.After(time.Second): + t.Fatal("expected upload registry event") } } -func TestApplyTrackerUploadProgressEmitsMeaningfulChangeWithinThrottle(t *testing.T) { - tests := []struct { - name string - update api.UploadProgressUpdate - }{ - { - name: "percent changed", - update: api.UploadProgressUpdate{ - Tracker: "BLU", - Status: "running", - Percent: 41, - }, - }, - { - name: "status changed", - update: api.UploadProgressUpdate{ - Tracker: "BLU", - Status: "completed", - Percent: 40, - }, - }, - { - name: "hash rate changed beyond threshold", - update: api.UploadProgressUpdate{ - Tracker: "BLU", - Status: "running", - Percent: 40, - HashRateMiB: 12, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hub := newEventHub() - ch, unsubscribe := hub.Subscribe("session") - defer unsubscribe() - backend := &Backend{hub: hub} - job := newTrackerUploadProgressTestJob() - job.lastSnapshotEmit = time.Now() - job.snapshotThrottle = time.Hour - job.currentTaskStatus = "running" - job.currentPercent = 40 - job.currentHashRateMiB = 10 - - backend.applyTrackerUploadProgress(job, tt.update) - - select { - case event := <-ch: - if event.Name != "upload:job:job" { - t.Fatalf("unexpected event name: %q", event.Name) - } - default: - t.Fatal("expected meaningful progress change to emit immediately") - } - }) - } +type blockingUploadRunner struct { + started chan<- struct{} + canceled chan<- struct{} + release <-chan struct{} } -func TestApplyTrackerUploadProgressUpdatesOnlyNamedTrackerForMultiTrackerJobs(t *testing.T) { - hub := newEventHub() - backend := &Backend{hub: hub} - job := &trackerUploadJob{ - sessionID: "session", - id: "job", - trackers: []string{"BLU", "AITHER"}, - states: map[string]TrackerUploadTrackerState{ - "BLU": {Tracker: "BLU", Status: "running", Message: "queued"}, - "AITHER": {Tracker: "AITHER", Status: "running", Message: "queued"}, - }, - startedAt: time.Now().UTC(), - } - - backend.applyTrackerUploadProgress(job, api.UploadProgressUpdate{ - Tracker: "BLU", - Task: "tracker_upload", - Status: "running", - Message: "Uploading to tracker", - Percent: 40, - }) - - if got := job.states["BLU"].Task; got != "tracker_upload" { - t.Fatalf("expected BLU task to update, got %q", got) - } - if got := job.states["BLU"].Message; got != "Uploading to tracker" { - t.Fatalf("expected BLU message to update, got %q", got) +func (r blockingUploadRunner) RunUpload(ctx context.Context, _ api.UploadExecutionPlan) (api.Result, error) { + if r.started != nil { + r.started <- struct{}{} } - if got := job.states["AITHER"].Task; got != "" { - t.Fatalf("expected AITHER task to remain untouched, got %q", got) - } - if got := job.states["AITHER"].Message; got != "queued" { - t.Fatalf("expected AITHER message to remain queued, got %q", got) - } -} - -func newTrackerUploadProgressTestJob() *trackerUploadJob { - return &trackerUploadJob{ - sessionID: "session", - id: "job", - trackers: []string{"BLU"}, - states: map[string]TrackerUploadTrackerState{ - "BLU": {Tracker: "BLU", Status: "running", Message: "uploading"}, - }, - startedAt: time.Now().UTC(), + <-ctx.Done() + select { + case r.canceled <- struct{}{}: + default: } + <-r.release + return api.Result{}, fmt.Errorf("blocking upload: %w", ctx.Err()) } -func newTrackerUploadJobTestJob(coreSvc api.Core, trackers []string) *trackerUploadJob { - job := &trackerUploadJob{ - sessionID: "session", - id: "job", - sourcePath: `C:\Media\Movie.mkv`, - core: coreSvc, - trackers: trackers, - states: make(map[string]TrackerUploadTrackerState, len(trackers)), - status: "queued", - startedAt: time.Now().UTC(), - } - for _, tracker := range trackers { - job.states[tracker] = TrackerUploadTrackerState{Tracker: tracker, Status: "queued", Message: "queued"} - } - return job +type blockingDupeRunner struct { + started chan<- struct{} + canceled chan<- struct{} + release <-chan struct{} } -func newDupeCheckJobTestJob(sessionID string, sourcePath string, trackers []string) *dupeCheckJob { - job := &dupeCheckJob{ - sessionID: sessionID, - id: "dupe-job", - sourcePath: sourcePath, - trackers: trackers, - states: make(map[string]DupeCheckTrackerState, len(trackers)), - totalCount: len(trackers), - status: "queued", - startedAt: time.Now().UTC(), +func (r blockingDupeRunner) CheckDupes(ctx context.Context, _ api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + if r.started != nil { + r.started <- struct{}{} } - for _, tracker := range trackers { - job.states[tracker] = DupeCheckTrackerState{Tracker: tracker, Status: "queued", Message: "queued"} + <-ctx.Done() + select { + case r.canceled <- struct{}{}: + default: } - return job + <-r.release + return api.DupeCheckSummary{}, fmt.Errorf("blocking dupe: %w", ctx.Err()) } -func newDupeCheckStartTestBackend(t *testing.T) (*Backend, string) { +func installBlockingUploadJob( + t *testing.T, + backend *Backend, + ownerID string, + canceled chan<- struct{}, + release <-chan struct{}, +) string { t.Helper() - - tempDir := t.TempDir() - sourcePath := filepath.Join(tempDir, "Example.Release.2026.1080p-GRP.mkv") - if err := os.WriteFile(sourcePath, []byte("example"), 0o600); err != nil { - t.Fatalf("write source fixture: %v", err) + if backend.jobEngine == nil { + backend.jobEngine = sharedjobs.New(webJobSink{hub: backend.hub}, sharedjobs.Config{}) } - repoPath := filepath.Join(tempDir, "upbrr.db") - repo, err := db.OpenWithLogger(repoPath, api.NopLogger{}) + owner, err := backend.ensureJobOwner(ownerID) if err != nil { - t.Fatalf("open repo: %v", err) + t.Fatalf("ensure owner: %v", err) } - if err := repo.Migrate(); err != nil { - _ = repo.Close() - t.Fatalf("migrate repo: %v", err) - } - - backend := &Backend{ - cfg: config.Config{ - MainSettings: config.MainSettingsConfig{DBPath: repoPath, TMDBAPI: "x"}, - ScreenshotHandling: config.ScreenshotHandlingConfig{Screens: 1}, - }, - repo: repo, - dupes: make(map[string]*dupeCheckJob), - hub: newEventHub(), - } - t.Cleanup(func() { - backend.stopAllDupeJobs() - _ = repo.Close() + jobID, err := backend.jobEngine.StartUpload(context.Background(), owner, sharedjobs.UploadSpec{ + CorrelationID: "blocking-upload", + Snapshot: webTestUploadSnapshot("Example.Release.2026.1080p-GRP"), + Runner: blockingUploadRunner{canceled: canceled, release: release}, }) - return backend, sourcePath -} - -func dupeJobCount(backend *Backend) int { - backend.dupeMu.Lock() - defer backend.dupeMu.Unlock() - return len(backend.dupes) -} - -func assertPreparedMetaExportRequest(req api.Request, expected *api.Request) error { - if expected == nil { - return nil - } - if !reflect.DeepEqual(req.Paths, expected.Paths) { - return fmt.Errorf("metadata preview cache request paths: got %#v want %#v", req.Paths, expected.Paths) - } - if req.Mode != expected.Mode { - return fmt.Errorf("metadata preview cache request mode: got %q want %q", req.Mode, expected.Mode) - } - if !reflect.DeepEqual(req.Trackers, expected.Trackers) { - return fmt.Errorf("metadata preview cache request trackers: got %#v want %#v", req.Trackers, expected.Trackers) - } - if !reflect.DeepEqual(req.ExternalIDOverrides, expected.ExternalIDOverrides) { - return fmt.Errorf("metadata preview cache request external overrides: got %#v want %#v", req.ExternalIDOverrides, expected.ExternalIDOverrides) - } - if !reflect.DeepEqual(req.ReleaseNameOverrides, expected.ReleaseNameOverrides) { - return fmt.Errorf("metadata preview cache request name overrides: got %#v want %#v", req.ReleaseNameOverrides, expected.ReleaseNameOverrides) + if err != nil { + t.Fatalf("start blocking upload: %v", err) } - return nil + return jobID } -func assertSnapshotHasNoActiveTrackerStates(t *testing.T, snapshot TrackerUploadSnapshot) { +func installBlockingDupeJob( + t *testing.T, + backend *Backend, + ownerID string, + canceled chan<- struct{}, + release <-chan struct{}, +) string { t.Helper() - if snapshot.Status != "canceled" { - t.Fatalf("expected canceled snapshot status, got %q", snapshot.Status) + if backend.jobEngine == nil { + backend.jobEngine = sharedjobs.New(webJobSink{hub: backend.hub}, sharedjobs.Config{}) } - for _, tracker := range snapshot.Trackers { - if tracker.Status == "queued" || tracker.Status == "running" { - t.Fatalf("expected snapshot tracker %s terminal, got %q", tracker.Tracker, tracker.Status) - } - } -} - -func TestSeedRunCorePreparedMetaCopiesPreparedMetadata(t *testing.T) { - source := &preparedMetaTestCore{ - exportFound: true, - exportedMeta: api.PreparedMetadata{SourcePath: "C:\\releases\\movie.mkv"}, - } - target := &preparedMetaTestCore{} - req := api.Request{ - Paths: []string{"C:\\releases\\movie.mkv"}, - Mode: api.ModeGUI, - } - - if err := guishared.SeedRunCorePreparedMeta(context.Background(), source, target, req); err != nil { - t.Fatalf("seed run core prepared meta: %v", err) - } - if target.importedMeta.SourcePath != "C:\\releases\\movie.mkv" { - t.Fatalf("expected imported source path, got %q", target.importedMeta.SourcePath) - } - if len(target.importedReq.Paths) != 1 || target.importedReq.Paths[0] != "C:\\releases\\movie.mkv" { - t.Fatalf("expected import request paths to be preserved, got %#v", target.importedReq.Paths) - } -} - -func TestSeedRunCorePreparedMetaSkipsWhenNoCacheFound(t *testing.T) { - source := &preparedMetaTestCore{} - target := &preparedMetaTestCore{} - - if err := guishared.SeedRunCorePreparedMeta(context.Background(), source, target, api.Request{ - Paths: []string{"C:\\releases\\movie.mkv"}, - Mode: api.ModeGUI, - }); err != nil { - t.Fatalf("seed run core prepared meta: %v", err) + owner, err := backend.ensureJobOwner(ownerID) + if err != nil { + t.Fatalf("ensure owner: %v", err) } - if target.importedMeta.SourcePath != "" { - t.Fatalf("expected no metadata import when cache missing, got %#v", target.importedMeta) + jobID, err := backend.jobEngine.StartDupe(context.Background(), owner, sharedjobs.DupeSpec{ + CorrelationID: "blocking-dupe", + Snapshot: webTestDupeSnapshot("Example.Release.2026.1080p-GRP"), + Runner: blockingDupeRunner{canceled: canceled, release: release}, + }) + if err != nil { + t.Fatalf("start blocking dupe: %v", err) } + return jobID } diff --git a/internal/webserver/native_picker.go b/internal/webserver/native_picker.go deleted file mode 100644 index 285e16c7..00000000 --- a/internal/webserver/native_picker.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package webserver - -type nativePicker interface { - BrowseFile() (string, error) - BrowseImageFiles() ([]string, error) - BrowseFolder() (string, error) -} diff --git a/internal/webserver/native_picker_nonwindows.go b/internal/webserver/native_picker_nonwindows.go deleted file mode 100644 index e9e43bb4..00000000 --- a/internal/webserver/native_picker_nonwindows.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build !windows - -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package webserver - -import "errors" - -type unsupportedNativePicker struct{} - -var errNativeBrowseWindowsOnly = errors.New("native browse is only available on Windows") - -func newNativePicker() nativePicker { - return unsupportedNativePicker{} -} - -func (unsupportedNativePicker) BrowseFile() (string, error) { - return "", errNativeBrowseWindowsOnly -} - -func (unsupportedNativePicker) BrowseImageFiles() ([]string, error) { - return nil, errNativeBrowseWindowsOnly -} - -func (unsupportedNativePicker) BrowseFolder() (string, error) { - return "", errNativeBrowseWindowsOnly -} diff --git a/internal/webserver/native_picker_windows.go b/internal/webserver/native_picker_windows.go deleted file mode 100644 index 008d626e..00000000 --- a/internal/webserver/native_picker_windows.go +++ /dev/null @@ -1,168 +0,0 @@ -//go:build windows - -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package webserver - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "strings" - "unicode/utf16" - "unicode/utf8" - - "github.com/autobrr/upbrr/internal/filesystem" -) - -type powershellNativePicker struct{} - -func newNativePicker() nativePicker { - return powershellNativePicker{} -} - -func (powershellNativePicker) BrowseFile() (string, error) { - filterPattern := videoFilePickerFilterPattern() - return runPickerScript(fmt.Sprintf(` -Add-Type -AssemblyName System.Windows.Forms -$dialog = New-Object System.Windows.Forms.OpenFileDialog -$dialog.Title = 'Select a file' -$dialog.Filter = 'Video files (%[1]s)|%[1]s' -if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { - [Console]::Out.Write($dialog.FileName) -} -`, filterPattern)) -} - -func (powershellNativePicker) BrowseImageFiles() ([]string, error) { - output, err := runPickerScript(` -Add-Type -AssemblyName System.Windows.Forms -$dialog = New-Object System.Windows.Forms.OpenFileDialog -$dialog.Title = 'Select images' -$dialog.Filter = 'Image files (*.png;*.jpg;*.jpeg;*.webp)|*.png;*.jpg;*.jpeg;*.webp|All files (*.*)|*.*' -$dialog.Multiselect = $true -if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { - [Console]::Out.Write(($dialog.FileNames -join [Environment]::NewLine)) -} -`) - if err != nil { - return nil, err - } - var paths []string - for line := range strings.SplitSeq(output, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - paths = append(paths, trimmed) - } - } - return paths, nil -} - -func (powershellNativePicker) BrowseFolder() (string, error) { - return runPickerScript(` -Add-Type -AssemblyName System.Windows.Forms -$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -$dialog.Description = 'Select a folder' -$dialog.ShowNewFolderButton = $false -if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { - [Console]::Out.Write($dialog.SelectedPath) -} -`) -} - -func runPickerScript(script string) (string, error) { - wrapped := strings.TrimSpace(` -$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) -` + "\n" + script) - - cmd := exec.CommandContext( - context.Background(), - "powershell", - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-STA", - "-Command", - wrapped, - ) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - message := strings.TrimSpace(stderr.String()) - if message == "" { - message = err.Error() - } - return "", fmt.Errorf("native browse failed: %s", message) - } - return decodePickerOutput(stdout.Bytes()), nil -} - -func videoFilePickerFilterPattern() string { - extensions := filesystem.SupportedVideoExtensions() - patterns := make([]string, 0, len(extensions)) - for _, ext := range extensions { - patterns = append(patterns, "*"+ext) - } - return strings.Join(patterns, ";") -} - -func decodePickerOutput(raw []byte) string { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { - return "" - } - - if utf8.Valid(trimmed) { - return strings.TrimSpace(string(trimmed)) - } - - if len(trimmed) >= 2 { - if trimmed[0] == 0xFF && trimmed[1] == 0xFE { - if decoded, ok := decodeUTF16(trimmed[2:], true); ok { - return decoded - } - } - if trimmed[0] == 0xFE && trimmed[1] == 0xFF { - if decoded, ok := decodeUTF16(trimmed[2:], false); ok { - return decoded - } - } - } - - if decoded, ok := decodeUTF16(trimmed, true); ok { - return decoded - } - if decoded, ok := decodeUTF16(trimmed, false); ok { - return decoded - } - - return strings.TrimSpace(string(trimmed)) -} - -func decodeUTF16(raw []byte, littleEndian bool) (string, bool) { - if len(raw) < 2 || len(raw)%2 != 0 { - return "", false - } - - u16 := make([]uint16, 0, len(raw)/2) - for i := 0; i < len(raw); i += 2 { - if littleEndian { - u16 = append(u16, uint16(raw[i])|uint16(raw[i+1])<<8) - } else { - u16 = append(u16, uint16(raw[i])<<8|uint16(raw[i+1])) - } - } - - decoded := strings.TrimSpace(string(utf16.Decode(u16))) - if decoded == "" { - return "", false - } - if strings.ContainsRune(decoded, '\uFFFD') { - return "", false - } - return decoded, true -} diff --git a/internal/webserver/native_picker_windows_test.go b/internal/webserver/native_picker_windows_test.go deleted file mode 100644 index 613ac4a4..00000000 --- a/internal/webserver/native_picker_windows_test.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build windows - -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package webserver - -import "testing" - -func TestDecodePickerOutputUTF8(t *testing.T) { - input := []byte("D:\\Movies\\Example Movie 2026 2160p AUS UHD Blu-ray DV HDR HEVC DTS-HD MA 5.1-GRP\u2122") - got := decodePickerOutput(input) - want := "D:\\Movies\\Example Movie 2026 2160p AUS UHD Blu-ray DV HDR HEVC DTS-HD MA 5.1-GRP\u2122" - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - -func TestDecodePickerOutputUTF16LEBOM(t *testing.T) { - path := "D:\\Movies\\Example Movie 2026 2160p AUS UHD Blu-ray DV HDR HEVC DTS-HD MA 5.1-GRP\u2122" - bytes := []byte{0xFF, 0xFE} - for _, r := range path { - if r <= 0xFFFF { - bytes = append(bytes, byte(r), byte(r>>8)) - } - } - got := decodePickerOutput(bytes) - if got != path { - t.Fatalf("expected %q, got %q", path, got) - } -} diff --git a/internal/webserver/preparation_progress_test.go b/internal/webserver/preparation_progress_test.go new file mode 100644 index 00000000..9295ce66 --- /dev/null +++ b/internal/webserver/preparation_progress_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/autobrr/upbrr/pkg/api" +) + +type cancellationAwarePlaylistCapability struct { + observed chan error +} + +func (c cancellationAwarePlaylistCapability) DiscoverPlaylists(ctx context.Context, _ string) ([]api.PlaylistInfo, error) { + if err := ctx.Err(); err != nil { + c.observed <- err + return nil, fmt.Errorf("discover playlists canceled: %w", err) + } + return nil, nil +} + +func TestPreparationProgressBoundaryCorrelatesAndSanitizesEvents(t *testing.T) { + hub := newEventHub() + backend := &Backend{hub: hub} + events, unsubscribe := hub.Subscribe("session-1") + t.Cleanup(unsubscribe) + + ctx, err := backend.withPreparationProgress(context.Background(), "session-1", "attempt-1") + if err != nil { + t.Fatalf("create preparation progress context: %v", err) + } + api.EmitPreparationProgress(ctx, api.NewPreparationProgressUpdate( + api.PreparationPhaseClientDiscovery, + api.PreparationProgressRunning, + `source=C:\media\Example.Release.2026.1080p-GRP api_key=synthetic-secret`, + )) + + select { + case event := <-events: + if event.Name != "preparation:progress" { + t.Fatalf("event name=%q", event.Name) + } + var update api.PreparationProgressUpdate + if err := json.Unmarshal(event.Data, &update); err != nil { + t.Fatalf("decode preparation progress: %v", err) + } + if update.CorrelationID != "attempt-1" || update.Timestamp == "" { + t.Fatal("preparation event is missing correlation or timestamp") + } + if strings.Contains(update.Message, "synthetic-secret") || strings.Contains(update.Message, `C:\media`) { + t.Fatal("preparation event exposed sensitive or local-path detail") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for preparation event") + } +} + +func TestPreparationProgressBoundaryRejectsEmptyCorrelation(t *testing.T) { + backend := &Backend{hub: newEventHub()} + if _, err := backend.withPreparationProgress(context.Background(), "session-1", " "); err == nil { + t.Fatal("empty preparation correlation ID was accepted") + } +} + +func TestDiscoverPlaylistsPropagatesRequestCancellation(t *testing.T) { + observed := make(chan error, 1) + backend := &Backend{ + capabilities: CoreCapabilities{Playlists: cancellationAwarePlaylistCapability{observed: observed}}, + hub: newEventHub(), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _ = backend.DiscoverPlaylists(ctx, "synthetic-source") + select { + case err := <-observed: + if !errors.Is(err, context.Canceled) { + t.Fatal("playlist capability did not receive request cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("playlist capability did not observe request cancellation") + } +} diff --git a/internal/webserver/prepared_meta.go b/internal/webserver/prepared_meta.go new file mode 100644 index 00000000..05daa02d --- /dev/null +++ b/internal/webserver/prepared_meta.go @@ -0,0 +1,37 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "errors" + "reflect" +) + +var ( + // ErrPreparedGenerationUnavailable reports an incomplete canonical generation bundle. + ErrPreparedGenerationUnavailable = errors.New("prepared generation capability unavailable") + // ErrPreparedUploadUnavailable reports a bundle missing upload execution or transfer support. + ErrPreparedUploadUnavailable = errors.New("prepared upload capability unavailable") + // ErrPreparedDupeUnavailable reports a bundle missing dupe execution or transfer support. + ErrPreparedDupeUnavailable = errors.New("prepared dupe capability unavailable") + // ErrPreparedDVDUnavailable reports a bundle missing DVD execution or transfer support. + ErrPreparedDVDUnavailable = errors.New("prepared DVD capability unavailable") + // ErrPreparedDryRunUnavailable reports a bundle missing dry-run execution or transfer support. + ErrPreparedDryRunUnavailable = errors.New("prepared dry-run capability unavailable") +) + +func capabilityIsNil(capability any) bool { + if capability == nil { + return true + } + value := reflect.ValueOf(capability) + kind := value.Kind() + isNilable := kind == reflect.Chan || + kind == reflect.Func || + kind == reflect.Interface || + kind == reflect.Map || + kind == reflect.Pointer || + kind == reflect.Slice + return isNilable && value.IsNil() +} diff --git a/internal/webserver/prepared_meta_test.go b/internal/webserver/prepared_meta_test.go new file mode 100644 index 00000000..4dea51f2 --- /dev/null +++ b/internal/webserver/prepared_meta_test.go @@ -0,0 +1,212 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "testing" + + "github.com/autobrr/upbrr/internal/preparedrelease" + "github.com/autobrr/upbrr/pkg/api" +) + +type preparedGenerationStub struct{} + +func (preparedGenerationStub) PrepareRelease(_ context.Context, input api.PrepareInput) (api.PrepareResult, error) { + return api.PrepareResult{Release: api.PreparedRelease{ + Generation: 1, + Source: api.SourceManifest{SourcePath: input.SourcePath}, + }}, nil +} + +func (preparedGenerationStub) ExportReleaseSeed(context.Context, api.ReleaseRef) (preparedrelease.Seed, error) { + return preparedrelease.Seed{}, nil +} + +func (preparedGenerationStub) ImportReleaseSeed(context.Context, preparedrelease.Seed) (api.ReleaseRef, error) { + return api.ReleaseRef{}, nil +} + +type preparedUploadStub struct{} + +func (preparedUploadStub) RunAcceptedUpload(context.Context, api.UploadExecutionPlan) (api.Result, error) { + return api.Result{}, nil +} + +type preparedDupeStub struct{} + +func (preparedDupeStub) CheckAcceptedDupes(context.Context, api.DuplicateCheckInput) (api.DupeCheckSummary, error) { + return api.DupeCheckSummary{}, nil +} + +type preparedDryRunStub struct{} + +func (preparedDryRunStub) RunAcceptedTrackerDryRun(context.Context, api.TrackerDryRunPlan) (api.TrackerDryRunPreview, error) { + return api.TrackerDryRunPreview{}, nil +} + +type preparedDVDStub struct{} + +func (preparedDVDStub) CaptureAcceptedDVDMenus(context.Context, api.MediaPlanInput) (api.DVDMenuCaptureResult, error) { + return api.DVDMenuCaptureResult{}, nil +} + +func (preparedDVDStub) ListAcceptedDVDMenuScreenshots(context.Context, api.MediaPlanInput) ([]api.ScreenshotImage, error) { + return nil, nil +} + +func (preparedDVDStub) DeleteAcceptedDVDMenuScreenshot(context.Context, api.MediaPlanInput, string) error { + return nil +} + +func TestCoreCapabilitiesPreparedOperationReadinessRejectsPartialBundles(t *testing.T) { + t.Parallel() + + generationOnly := CoreCapabilities{ReleasePreparation: preparedGenerationStub{}, PreparedGenerationTransfer: preparedGenerationStub{}} + if !generationOnly.Available() { + t.Fatal("expected generation-only test bundle to remain available") + } + if generationOnly.PreparedUploadReady() || generationOnly.PreparedDupeReady() || generationOnly.PreparedDVDReady() || generationOnly.PreparedDryRunReady() { + t.Fatal("expected generation-only bundle to reject prepared operations") + } + + withoutGeneration := CoreCapabilities{ + UploadExecution: preparedUploadStub{}, + DuplicateExecution: preparedDupeStub{}, + DVD: preparedDVDStub{}, + DryRun: preparedDryRunStub{}, + } + if !withoutGeneration.Available() { + t.Fatal("expected workflow-only test bundle to remain available") + } + if withoutGeneration.PreparedUploadReady() || withoutGeneration.PreparedDupeReady() || withoutGeneration.PreparedDVDReady() { + t.Fatal("expected background workflows without generation support to reject prepared operations") + } + if !withoutGeneration.PreparedDryRunReady() { + t.Fatal("expected active-runtime dry run to remain available without generation transfer") + } + + complete := withoutGeneration + complete.ReleasePreparation = preparedGenerationStub{} + complete.PreparedGenerationTransfer = preparedGenerationStub{} + if !complete.PreparedUploadReady() || !complete.PreparedDupeReady() || !complete.PreparedDVDReady() || !complete.PreparedDryRunReady() { + t.Fatal("expected complete prepared-operation bundle to be ready") + } +} + +func TestCoreCapabilitiesPreparedOperationReadinessRejectsTypedNilCapabilities(t *testing.T) { + t.Parallel() + + var typedNilGeneration *preparedGenerationStub + var typedNilUpload *preparedUploadStub + var typedNilDupe *preparedDupeStub + var typedNilDVD *preparedDVDStub + var typedNilDryRun *preparedDryRunStub + + typedNilOnly := CoreCapabilities{ + ReleasePreparation: typedNilGeneration, + PreparedGenerationTransfer: typedNilGeneration, + DryRun: typedNilDryRun, + DuplicateExecution: typedNilDupe, + UploadExecution: typedNilUpload, + DVD: typedNilDVD, + } + if typedNilOnly.Available() { + t.Fatal("expected typed-nil-only bundle to be unavailable") + } + if typedNilOnly.PreparedGenerationReady() || typedNilOnly.PreparedUploadReady() || typedNilOnly.PreparedDupeReady() || + typedNilOnly.PreparedDVDReady() || typedNilOnly.PreparedDryRunReady() { + t.Fatal("expected typed-nil generation to make every prepared operation unavailable") + } + + complete := CoreCapabilities{ + ReleasePreparation: preparedGenerationStub{}, + DryRun: preparedDryRunStub{}, + DuplicateExecution: preparedDupeStub{}, + UploadExecution: preparedUploadStub{}, + DVD: preparedDVDStub{}, + PreparedGenerationTransfer: preparedGenerationStub{}, + } + tests := []struct { + name string + bundle CoreCapabilities + ready func(CoreCapabilities) bool + otherReady []func(CoreCapabilities) bool + }{ + { + name: "upload", + bundle: func() CoreCapabilities { + bundle := complete + bundle.UploadExecution = typedNilUpload + return bundle + }(), + ready: CoreCapabilities.PreparedUploadReady, + otherReady: []func(CoreCapabilities) bool{ + CoreCapabilities.PreparedDupeReady, + CoreCapabilities.PreparedDVDReady, + CoreCapabilities.PreparedDryRunReady, + }, + }, + { + name: "dupe", + bundle: func() CoreCapabilities { + bundle := complete + bundle.DuplicateExecution = typedNilDupe + return bundle + }(), + ready: CoreCapabilities.PreparedDupeReady, + otherReady: []func(CoreCapabilities) bool{ + CoreCapabilities.PreparedUploadReady, + CoreCapabilities.PreparedDVDReady, + CoreCapabilities.PreparedDryRunReady, + }, + }, + { + name: "DVD", + bundle: func() CoreCapabilities { + bundle := complete + bundle.DVD = typedNilDVD + return bundle + }(), + ready: CoreCapabilities.PreparedDVDReady, + otherReady: []func(CoreCapabilities) bool{ + CoreCapabilities.PreparedUploadReady, + CoreCapabilities.PreparedDupeReady, + CoreCapabilities.PreparedDryRunReady, + }, + }, + { + name: "dry run", + bundle: func() CoreCapabilities { + bundle := complete + bundle.DryRun = typedNilDryRun + return bundle + }(), + ready: CoreCapabilities.PreparedDryRunReady, + otherReady: []func(CoreCapabilities) bool{ + CoreCapabilities.PreparedUploadReady, + CoreCapabilities.PreparedDupeReady, + CoreCapabilities.PreparedDVDReady, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.ready(tt.bundle) { + t.Fatal("expected typed-nil operation capability to be unavailable") + } + for _, ready := range tt.otherReady { + if !ready(tt.bundle) { + t.Fatal("expected valid sibling operation capability to remain ready") + } + } + if !tt.bundle.Available() { + t.Fatal("expected valid partial bundle capabilities to remain available") + } + }) + } +} diff --git a/internal/webserver/review_registry.go b/internal/webserver/review_registry.go new file mode 100644 index 00000000..1803654e --- /dev/null +++ b/internal/webserver/review_registry.go @@ -0,0 +1,183 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/autobrr/upbrr/internal/webserver/jobs" + "github.com/autobrr/upbrr/pkg/api" +) + +const uploadReviewLifetime = 30 * time.Minute + +var ( + // ErrUploadReviewNotFound intentionally covers unknown, expired, consumed, + // and foreign-owner tokens without revealing token existence. + ErrUploadReviewNotFound = errors.New("upload review not found") +) + +// UploadReviewSnapshot is the owner-scoped handoff from review to execution. +type UploadReviewSnapshot struct { + Execution jobs.UploadExecutionSnapshot + Review api.UploadReview +} + +type uploadReviewEntry struct { + owner string + expiresAt time.Time + snapshot UploadReviewSnapshot +} + +// UploadReviewRegistry issues opaque, single-use, owner-scoped review tokens. +type UploadReviewRegistry struct { + mu sync.Mutex + entries map[string]uploadReviewEntry + now func() time.Time +} + +// NewUploadReviewRegistry constructs an empty registry. +func NewUploadReviewRegistry() *UploadReviewRegistry { + return &UploadReviewRegistry{ + entries: make(map[string]uploadReviewEntry), + now: func() time.Time { return time.Now().UTC() }, + } +} + +// Issue stores an immutable snapshot and returns an unguessable token. +func (r *UploadReviewRegistry) Issue(owner string, snapshot UploadReviewSnapshot) (string, error) { + owner = strings.TrimSpace(owner) + if r == nil || owner == "" { + return "", errors.New("upload review owner is required") + } + if err := validateUploadReviewSnapshot(snapshot); err != nil { + return "", err + } + cloned, err := cloneUploadReviewSnapshot(snapshot) + if err != nil { + return "", err + } + token, err := newUploadReviewToken() + if err != nil { + return "", fmt.Errorf("create upload review token: %w", err) + } + cloned.Execution.ReviewToken = token + r.mu.Lock() + r.initializeLocked() + now := r.now().UTC() + r.pruneLocked(now) + r.entries[token] = uploadReviewEntry{ + owner: owner, + expiresAt: now.Add(uploadReviewLifetime), + snapshot: cloned, + } + r.mu.Unlock() + return token, nil +} + +// Consume returns and deletes one snapshot. Foreign-owner, expired, and +// already-consumed tokens have the same result. +func (r *UploadReviewRegistry) Consume(owner string, token string) (UploadReviewSnapshot, error) { + owner = strings.TrimSpace(owner) + token = strings.TrimSpace(token) + if r == nil || owner == "" || token == "" { + return UploadReviewSnapshot{}, ErrUploadReviewNotFound + } + r.mu.Lock() + r.initializeLocked() + now := r.now().UTC() + r.pruneLocked(now) + entry, ok := r.entries[token] + if !ok || entry.owner != owner { + r.mu.Unlock() + return UploadReviewSnapshot{}, ErrUploadReviewNotFound + } + delete(r.entries, token) + r.mu.Unlock() + cloned, err := cloneUploadReviewSnapshot(entry.snapshot) + if err != nil { + return UploadReviewSnapshot{}, fmt.Errorf("clone upload review snapshot: %w", err) + } + return cloned, nil +} + +// PurgeOwner removes unconsumed reviews when an owner/session ends. +func (r *UploadReviewRegistry) PurgeOwner(owner string) { + if r == nil { + return + } + owner = strings.TrimSpace(owner) + r.mu.Lock() + for token, entry := range r.entries { + if entry.owner == owner { + delete(r.entries, token) + } + } + r.mu.Unlock() +} + +func (r *UploadReviewRegistry) initializeLocked() { + if r.entries == nil { + r.entries = make(map[string]uploadReviewEntry) + } + if r.now == nil { + r.now = func() time.Time { return time.Now().UTC() } + } +} + +func (r *UploadReviewRegistry) pruneLocked(now time.Time) { + for token, entry := range r.entries { + if !entry.expiresAt.After(now) { + delete(r.entries, token) + } + } +} + +func validateUploadReviewSnapshot(snapshot UploadReviewSnapshot) error { + execution := snapshot.Execution + if execution.PreparedGeneration == 0 || execution.RuntimeGeneration == 0 || execution.Input.Release.Generation == 0 || + execution.PreparedGeneration != execution.Input.Release.Generation || strings.TrimSpace(execution.Input.Release.SourcePath) == "" { + return errors.New("upload review snapshot has invalid generation lineage") + } + return nil +} + +func cloneUploadReviewSnapshot(snapshot UploadReviewSnapshot) (UploadReviewSnapshot, error) { + type clonePayload struct { + Input api.UploadReviewInput + Outcome api.UploadReviewOutcome + Review api.UploadReview + } + payload, err := json.Marshal(clonePayload{ + Input: snapshot.Execution.Input, + Outcome: snapshot.Execution.Outcome, + Review: snapshot.Review, + }) + if err != nil { + return UploadReviewSnapshot{}, fmt.Errorf("clone upload review snapshot: marshal: %w", err) + } + var cloned clonePayload + if err := json.Unmarshal(payload, &cloned); err != nil { + return UploadReviewSnapshot{}, fmt.Errorf("clone upload review snapshot: unmarshal: %w", err) + } + snapshot.Execution.Input = cloned.Input + snapshot.Execution.Outcome = cloned.Outcome + snapshot.Review = cloned.Review + return snapshot, nil +} + +func newUploadReviewToken() (string, error) { + var bytes [32]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", fmt.Errorf("generate upload review token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(bytes[:]), nil +} diff --git a/internal/webserver/review_registry_test.go b/internal/webserver/review_registry_test.go new file mode 100644 index 00000000..e100a8e8 --- /dev/null +++ b/internal/webserver/review_registry_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "errors" + "testing" + + "github.com/autobrr/upbrr/internal/webserver/jobs" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestUploadReviewRegistryOwnerScopeSingleUseAndCopies(t *testing.T) { + t.Parallel() + registry := NewUploadReviewRegistry() + snapshot := UploadReviewSnapshot{ + Execution: jobs.UploadExecutionSnapshot{ + PreparedGeneration: 7, + RuntimeGeneration: 11, + Input: api.UploadReviewInput{ + Release: api.ReleaseRef{SourcePath: `C:\Example\Example.Release.2026.1080p-GRP.mkv`, Generation: 7}, + Trackers: []string{"EXAMPLE"}, + }, + }, + Review: api.UploadReview{Trackers: []api.TrackerReview{{Tracker: "EXAMPLE"}}}, + } + token, err := registry.Issue("owner-a", snapshot) + if err != nil { + t.Fatalf("Issue: %v", err) + } + snapshot.Execution.Input.Trackers[0] = "MUTATED" + if _, err := registry.Consume("owner-b", token); !errors.Is(err, ErrUploadReviewNotFound) { + t.Fatalf("foreign Consume error = %v", err) + } + consumed, err := registry.Consume("owner-a", token) + if err != nil { + t.Fatalf("Consume: %v", err) + } + if consumed.Execution.Input.Trackers[0] != "EXAMPLE" || consumed.Review.Trackers[0].Tracker != "EXAMPLE" { + t.Fatalf("snapshot was not isolated: %#v", consumed) + } + if _, err := registry.Consume("owner-a", token); !errors.Is(err, ErrUploadReviewNotFound) { + t.Fatalf("second Consume error = %v", err) + } +} + +func TestUploadReviewRegistryRejectsMissingLineage(t *testing.T) { + t.Parallel() + if _, err := NewUploadReviewRegistry().Issue("owner", UploadReviewSnapshot{}); err == nil { + t.Fatal("expected invalid lineage error") + } +} diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 53ea457c..be47d593 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -307,7 +307,6 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request, _ sess "needsSetup": false, "username": current.Username, "csrfToken": current.CSRFToken, - "nativeBrowseEnabled": s.nativeBrowseAvailable(r), "caseInsensitivePaths": runtime.GOOS == "windows", "browseRoot": "", "allowUnrestrictedBrowse": true, @@ -322,13 +321,11 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request, _ sess return } current, ok := s.currentSession(r) - browseAvailable := s.nativeBrowseAvailable(r) payload := map[string]any{ "authenticated": ok, "needsSetup": !exists, "username": "", "csrfToken": "", - "nativeBrowseEnabled": browseAvailable, "caseInsensitivePaths": runtime.GOOS == "windows", "browseRoot": "", "allowUnrestrictedBrowse": false, @@ -391,7 +388,6 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request, _ sessi "needsSetup": false, "username": current.Username, "csrfToken": current.CSRFToken, - "nativeBrowseEnabled": s.nativeBrowseAvailable(r), "caseInsensitivePaths": runtime.GOOS == "windows", "browseRoot": "", "allowUnrestrictedBrowse": false, @@ -502,7 +498,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request, _ session) "needsSetup": false, "username": current.Username, "csrfToken": current.CSRFToken, - "nativeBrowseEnabled": s.nativeBrowseAvailable(r), "caseInsensitivePaths": runtime.GOOS == "windows", "browseRoot": joinBrowsePolicyRoots(browseRoots), "allowUnrestrictedBrowse": record.AllowUnrestrictedBrowse, @@ -522,6 +517,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, current se } if s.backend != nil { s.backend.StopSessionLogStreams(current.ID) + s.backend.removeJobOwner(current.ID) } if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value == current.ID { //nolint:gosec // Session clear cookie sets HttpOnly, SameSite, and Secure for HTTPS requests. @@ -558,7 +554,11 @@ func (s *Server) handleBrowsePolicy(w http.ResponseWriter, r *http.Request, curr return } if !req.AllowUnrestrictedBrowse && len(roots) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one browse root is required unless unrestricted browsing is explicitly allowed"}) + writeJSON( + w, + http.StatusBadRequest, + map[string]string{"error": "at least one browse root is required unless unrestricted browsing is explicitly allowed"}, + ) return } @@ -583,7 +583,6 @@ func (s *Server) handleBrowsePolicy(w http.ResponseWriter, r *http.Request, curr "needsSetup": false, "username": current.Username, "csrfToken": current.CSRFToken, - "nativeBrowseEnabled": s.nativeBrowseAvailable(r), "caseInsensitivePaths": runtime.GOOS == "windows", "browseRoot": joinBrowsePolicyRoots(roots), "allowUnrestrictedBrowse": req.AllowUnrestrictedBrowse, @@ -827,6 +826,12 @@ func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, se writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) return } + if s.backend != nil { + if _, err := s.backend.ensureJobOwner(current.ID); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "job runtime unavailable"}) + return + } + } if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { if !s.verifySameOrigin(r, current) || !s.verifyCSRF(r, current) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"}) @@ -934,6 +939,7 @@ func (s *Server) allowGeneralRequest(r *http.Request) bool { return s.generalLimiter.Allow(s.clientIP(r)) } +// verifyCSRF requires the request header token to match the cookie-bound session. func (s *Server) verifyCSRF(r *http.Request, current session) bool { token := strings.TrimSpace(r.Header.Get("X-Csrf-Token")) if token == "" { @@ -942,6 +948,8 @@ func (s *Server) verifyCSRF(r *http.Request, current session) bool { return token == current.CSRFToken } +// verifySameOrigin accepts an exact Origin or Referer host match. Development +// no-auth sessions additionally permit requests between loopback hosts. func (s *Server) verifySameOrigin(r *http.Request, current session) bool { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" { @@ -971,6 +979,8 @@ func isLoopbackHostPort(host string) bool { return isLoopbackHostname(strings.Trim(trimmed, "[]")) } +// clientIP trusts the first X-Forwarded-For value only when RemoteAddr belongs +// to a configured trusted proxy. func (s *Server) clientIP(r *http.Request) string { ip := ipFromAddr(r.RemoteAddr) if !s.isTrustedProxy(net.ParseIP(ip)) { @@ -983,13 +993,6 @@ func (s *Server) clientIP(r *http.Request) string { return forwarded } -func (s *Server) nativeBrowseAvailable(r *http.Request) bool { - if s == nil || s.picker == nil || r == nil { - return false - } - return s.isLocalWebUIRequest(r) -} - func (s *Server) isLocalWebUIRequest(r *http.Request) bool { if r == nil { return false @@ -1022,6 +1025,8 @@ func isLoopbackHostname(host string) bool { return ip != nil && ip.IsLoopback() } +// requestScheme honors an explicit HTTPS request URL, trusts +// X-Forwarded-Proto only from a configured proxy, then falls back to TLS state. func (s *Server) requestScheme(r *http.Request) string { if strings.EqualFold(r.URL.Scheme, "https") { return "https" diff --git a/internal/webserver/routes_auth_test.go b/internal/webserver/routes_auth_test.go index 5d7be090..0f0e6a85 100644 --- a/internal/webserver/routes_auth_test.go +++ b/internal/webserver/routes_auth_test.go @@ -7,12 +7,12 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" - "sync/atomic" "testing" "time" @@ -548,7 +548,6 @@ func TestDevelopmentNoAuthStatusBypassesMissingAuthOnLoopback(t *testing.T) { CSRFToken: "dev-csrf", ExpiresAt: time.Now().UTC().Add(time.Hour), }, - picker: &stubNativePicker{}, } req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/auth/status", nil) @@ -791,7 +790,6 @@ func TestLogoutRejectsMismatchedCSRFAndCookieSession(t *testing.T) { func TestRetainedSessionCanAccessAppRouteAfterRestart(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "state", "db.sqlite") server := newAuthTestServer(t, dbPath) - server.picker = &stubNativePicker{filePath: `C:\Media\movie.mkv`} current, err := server.sessions.Create("admin", true) if err != nil { @@ -800,12 +798,11 @@ func TestRetainedSessionCanAccessAppRouteAfterRestart(t *testing.T) { server.sessions.Close() reloaded := newAuthTestServer(t, dbPath) - reloaded.picker = &stubNativePicker{filePath: `C:\Media\movie.mkv`} mux := http.NewServeMux() reloaded.registerAppRoutes(mux) - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/BrowseFile", strings.NewReader(`{}`)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/GetApplicationInfo", strings.NewReader(`{}`)) req.Host = "127.0.0.1:7480" req.RemoteAddr = "127.0.0.1:5000" req.Header.Set("Content-Type", "application/json") @@ -824,20 +821,11 @@ func TestRetainedSessionCanAccessAppRouteAfterRestart(t *testing.T) { func TestTrackerAuthBackendUsesRequestContext(t *testing.T) { t.Parallel() - var requests atomic.Int32 - trackerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`authkey=abcdefghijklmnopqrstuvwxyzABCDEF`)) - })) - t.Cleanup(trackerServer.Close) - backend := &Backend{ cfg: config.Config{ Trackers: config.TrackersConfig{ Trackers: map[string]config.TrackerConfig{ "MTV": { - URL: trackerServer.URL, Username: "user", Password: "pass", }, @@ -852,9 +840,6 @@ func TestTrackerAuthBackendUsesRequestContext(t *testing.T) { if err != nil { t.Fatalf("TestTrackerAuth: %v", err) } - if requests.Load() != 0 { - t.Fatalf("expected canceled context to prevent remote auth request, got %d request(s)", requests.Load()) - } if !strings.Contains(status.LastError, "context canceled") { t.Fatalf("expected context canceled status, got %#v", status) } @@ -1072,23 +1057,15 @@ func TestCancelDupeCheckRequiresPost(t *testing.T) { } canceled := make(chan struct{}, 1) - server.backend.dupes = map[string]*dupeCheckJob{ - "job-1": { - sessionID: current.ID, - id: "job-1", - cancel: func() { - select { - case canceled <- struct{}{}: - default: - } - }, - }, - } + release := make(chan struct{}) + jobID := installBlockingDupeJob(t, server.backend, current.ID, canceled, release) + t.Cleanup(func() { close(release) }) + body := fmt.Sprintf(`{"JobID":%q}`, jobID) mux := http.NewServeMux() server.registerAppRoutes(mux) - getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/app/CancelDupeCheck", strings.NewReader(`{"JobID":"job-1"}`)) + getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/app/CancelDupeCheck", strings.NewReader(body)) getReq.Header.Set("Content-Type", "application/json") getReq.AddCookie(&http.Cookie{Name: sessionCookieName, Value: current.ID}) getRecorder := httptest.NewRecorder() @@ -1104,7 +1081,7 @@ func TestCancelDupeCheckRequiresPost(t *testing.T) { default: } - postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/CancelDupeCheck", strings.NewReader(`{"JobID":"job-1"}`)) + postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/CancelDupeCheck", strings.NewReader(body)) postReq.Host = "127.0.0.1:7480" postReq.Header.Set("Content-Type", "application/json") postReq.Header.Set("Origin", "http://127.0.0.1:7480") @@ -1133,23 +1110,15 @@ func TestCancelTrackerUploadRequiresPost(t *testing.T) { } canceled := make(chan struct{}, 1) - server.backend.uploads = map[string]*trackerUploadJob{ - "job-1": { - id: "job-1", - sessionID: current.ID, - cancel: func() { - select { - case canceled <- struct{}{}: - default: - } - }, - }, - } + release := make(chan struct{}) + jobID := installBlockingUploadJob(t, server.backend, current.ID, canceled, release) + t.Cleanup(func() { close(release) }) + body := fmt.Sprintf(`{"JobID":%q}`, jobID) mux := http.NewServeMux() server.registerAppRoutes(mux) - getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/app/CancelTrackerUpload", strings.NewReader(`{"JobID":"job-1"}`)) + getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/app/CancelTrackerUpload", strings.NewReader(body)) getReq.Header.Set("Content-Type", "application/json") getReq.AddCookie(&http.Cookie{Name: sessionCookieName, Value: current.ID}) getRecorder := httptest.NewRecorder() @@ -1165,7 +1134,7 @@ func TestCancelTrackerUploadRequiresPost(t *testing.T) { default: } - postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/CancelTrackerUpload", strings.NewReader(`{"JobID":"job-1"}`)) + postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/CancelTrackerUpload", strings.NewReader(body)) postReq.Host = "127.0.0.1:7480" postReq.Header.Set("Content-Type", "application/json") postReq.Header.Set("Origin", "http://127.0.0.1:7480") diff --git a/internal/webserver/runtime.go b/internal/webserver/runtime.go new file mode 100644 index 00000000..532fb513 --- /dev/null +++ b/internal/webserver/runtime.go @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/core" + "github.com/autobrr/upbrr/internal/filesystem" + "github.com/autobrr/upbrr/internal/logging" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +// RuntimeGeneration groups one activated config generation's workflow bundle, +// lifecycle owner, and logger. Capabilities borrow the core; the runtime +// activator transfers Owner and Logger to the installer as one generation. +type RuntimeGeneration struct { + ID uint64 + Config config.Config + Capabilities CoreCapabilities + Owner LifecycleOwner + Logger *logging.Logger +} + +var runtimeGenerationIDs atomic.Uint64 + +// AllocateRuntimeGenerationID returns a process-local monotonic identifier for +// one coherent runtime resource generation. +func AllocateRuntimeGenerationID() uint64 { + return runtimeGenerationIDs.Add(1) +} + +// buildRuntimeGeneration constructs a fresh generation from cfg using ctx and +// the shared repository. On failure it closes resources created before the +// error. Successful ownership transfers to the runtime activator. +func buildRuntimeGeneration(ctx context.Context, cfg config.Config, repo *db.SQLiteRepository) (RuntimeGeneration, error) { + if ctx == nil { + return RuntimeGeneration{}, errors.New("webserver: context is required") + } + + logger, err := logging.New(cfg.Logging, cfg.MainSettings.DBPath) + if err != nil { + return RuntimeGeneration{}, fmt.Errorf("web: %w", err) + } + svc, err := core.NewWithContext(ctx, api.CoreDependencies{ + Config: cfg, + Logger: logger, + Services: api.ServiceSet{ + Filesystem: filesystem.NewValidator(), + }, + Repository: repo.RepositoryCapabilities(), + RepositoryOwner: repo, + SkipCookieMigration: true, + }) + if err != nil { + _ = logger.Close() + return RuntimeGeneration{}, fmt.Errorf("web: %w", err) + } + capabilities, owner := BindCoreCapabilities(svc) + return RuntimeGeneration{ + ID: AllocateRuntimeGenerationID(), + Config: cfg, + Capabilities: capabilities, + Owner: owner, + Logger: logger, + }, nil +} diff --git a/internal/webserver/runtime_activation.go b/internal/webserver/runtime_activation.go new file mode 100644 index 00000000..b471f044 --- /dev/null +++ b/internal/webserver/runtime_activation.go @@ -0,0 +1,292 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + "github.com/autobrr/upbrr/internal/authmaterial" + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/configstore" + "github.com/autobrr/upbrr/internal/cookies" + "github.com/autobrr/upbrr/internal/logging" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +// ActivationStage identifies the failed stage of a runtime activation. +type ActivationStage string + +const ( + ActivationStageNormalize ActivationStage = "normalize candidate" + ActivationStageValidateStored ActivationStage = "validate stored config" + ActivationStageValidateRuntime ActivationStage = "validate runtime config" + ActivationStageBuild ActivationStage = "build runtime generation" + ActivationStageCookies ActivationStage = "maintain cookies" + ActivationStagePersist ActivationStage = "persist stored config" +) + +// ActivationError preserves the failed activation stage and wrapped cause. +type ActivationError struct { + Stage ActivationStage + Err error +} + +func (e *ActivationError) Error() string { + if e == nil { + return "runtime activation failed" + } + return fmt.Sprintf("runtime activation: %s: %v", e.Stage, e.Err) +} + +// Unwrap returns the stage cause. +func (e *ActivationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// RetiredRuntime owns the resources replaced by one successful installation. +type RetiredRuntime struct { + Owner LifecycleOwner + Logger *logging.Logger +} + +// Close releases the replaced runtime after its installer has finished all +// transport-specific rebinding. +func (r RetiredRuntime) Close() { + if r.Owner != nil { + _ = r.Owner.Close() + } + if r.Logger != nil { + _ = r.Logger.Close() + } +} + +// RuntimeInstaller atomically publishes one complete runtime generation and +// returns resources that can be retired after transport rebinding completes. +// Install must not fail. +type RuntimeInstaller interface { + Install(RuntimeGeneration) RetiredRuntime +} + +type runtimeActivationDeps struct { + build func(context.Context, config.Config, *db.SQLiteRepository) (RuntimeGeneration, error) + cookies func(context.Context, *db.SQLiteRepository, string, api.Logger) error + persist func(context.Context, *config.Config, *db.SQLiteRepository, string, api.Logger) error +} + +type runtimeCookiePersistenceError struct { + err error +} + +func (e *runtimeCookiePersistenceError) Error() string { + return fmt.Sprintf("runtime cookie persistence: %v", e.err) +} + +func (e *runtimeCookiePersistenceError) Unwrap() error { + return e.err +} + +// RuntimeActivator serializes and owns the complete config-candidate to active +// runtime transition for one WebUI host. +type RuntimeActivator struct { + mu sync.Mutex + repo *db.SQLiteRepository + fixedDBPath string + installer RuntimeInstaller + deps runtimeActivationDeps +} + +// NewRuntimeActivator constructs an activator for one already-open repository +// and one host-specific runtime installer. +func NewRuntimeActivator(repo *db.SQLiteRepository, fixedDBPath string, installer RuntimeInstaller) (*RuntimeActivator, error) { + if repo == nil { + return nil, errors.New("runtime activation: repository is required") + } + fixedDBPath = strings.TrimSpace(fixedDBPath) + if fixedDBPath == "" { + return nil, errors.New("runtime activation: fixed database path is required") + } + if installer == nil { + return nil, errors.New("runtime activation: installer is required") + } + return &RuntimeActivator{ + repo: repo, + fixedDBPath: fixedDBPath, + installer: installer, + deps: runtimeActivationDeps{ + build: buildRuntimeGeneration, + cookies: validateRuntimeCookies, + persist: persistRuntimeConfigAndCookies, + }, + }, nil +} + +// Activate normalizes, validates, builds, maintains, persists, and publishes a +// config candidate as one serialized runtime transition. +func (a *RuntimeActivator) Activate(ctx context.Context, candidate config.Config) error { + if a == nil { + return errors.New("runtime activation: activator is required") + } + if ctx == nil { + return errors.New("runtime activation: context is required") + } + + a.mu.Lock() + defer a.mu.Unlock() + + stored, err := cloneConfig(candidate) + if err != nil { + return activationError(ActivationStageNormalize, err) + } + if _, err := config.MergeMissingTrackerDefaults(stored); err != nil { + return activationError(ActivationStageNormalize, err) + } + stored.MainSettings.DBPath = a.fixedDBPath + if err := stored.Validate(); err != nil { + return activationError(ActivationStageValidateStored, err) + } + + runtimeCfg, err := cloneConfig(*stored) + if err != nil { + return activationError(ActivationStageNormalize, err) + } + config.ApplyEnvOverrides(runtimeCfg) + runtimeCfg.MainSettings.DBPath = a.fixedDBPath + if err := runtimeCfg.Validate(); err != nil { + return activationError(ActivationStageValidateRuntime, err) + } + + generation, err := a.deps.build(ctx, *runtimeCfg, a.repo) + if err != nil { + return activationError(ActivationStageBuild, err) + } + installed := false + defer func() { + if !installed { + RetiredRuntime{Owner: generation.Owner, Logger: generation.Logger}.Close() + } + }() + + if err := a.deps.cookies(ctx, a.repo, a.fixedDBPath, generation.Logger); err != nil { + return activationError(ActivationStageCookies, err) + } + if err := a.deps.persist(ctx, stored, a.repo, a.fixedDBPath, generation.Logger); err != nil { + var cookieErr *runtimeCookiePersistenceError + if errors.As(err, &cookieErr) { + return activationError(ActivationStageCookies, err) + } + return activationError(ActivationStagePersist, err) + } + + generation.Config = *runtimeCfg + retired := a.installer.Install(generation) + installed = true + retired.Close() + return nil +} + +func activationError(stage ActivationStage, err error) error { + return &ActivationError{Stage: stage, Err: err} +} + +func cloneConfig(source config.Config) (*config.Config, error) { + payload, err := json.Marshal(source) + if err != nil { + return nil, fmt.Errorf("clone config: marshal: %w", err) + } + var cloned config.Config + if err := json.Unmarshal(payload, &cloned); err != nil { + return nil, fmt.Errorf("clone config: unmarshal: %w", err) + } + return &cloned, nil +} + +func validateRuntimeCookies(_ context.Context, _ *db.SQLiteRepository, dbPath string, _ api.Logger) error { + if err := validateRuntimeCookieAuth(dbPath); err != nil && !errors.Is(err, cookies.ErrAuthHelperUnavailable) { + return fmt.Errorf("validate cookie auth: %w", err) + } + return nil +} + +func persistRuntimeConfigAndCookies( + ctx context.Context, + cfg *config.Config, + repo *db.SQLiteRepository, + dbPath string, + logger api.Logger, +) error { + if logger == nil { + logger = api.NopLogger{} + } + cookiesDir, err := db.CookiePath(dbPath, "") + if err != nil { + logger.Debugf("runtime activation: cookie directory unavailable: %v", err) + if err := configstore.SaveToRepository(ctx, cfg, repo, dbPath); err != nil { + return fmt.Errorf("persist runtime config without cookie directory: %w", err) + } + return nil + } + if !cookies.HasLegacyCookieFiles(cookiesDir) { + if err := configstore.SaveToRepository(ctx, cfg, repo, dbPath); err != nil { + return fmt.Errorf("persist runtime config without legacy cookies: %w", err) + } + return nil + } + + store, err := cookies.NewCookieStore(repo.RawDB()) + if err != nil { + return &runtimeCookiePersistenceError{err: fmt.Errorf("create cookie store: %w", err)} + } + + migratedCount := 0 + failedCookies := make([]cookies.FailedCookie, 0) + err = configstore.SaveToRepositoryWithPreSave(ctx, cfg, repo, dbPath, func(ctx context.Context, tx *sql.Tx, key []byte) error { + if len(key) == 0 { + logger.Debugf("runtime activation: cookie migration skipped: web auth helper unavailable") + return nil + } + var migrateErr error + migratedCount, failedCookies, migrateErr = cookies.MigrateFromFilesToDBTx(ctx, cookiesDir, store, tx, key, logger) + if migrateErr != nil { + return &runtimeCookiePersistenceError{err: fmt.Errorf("migrate cookies: %w", migrateErr)} + } + return nil + }) + if err != nil { + return fmt.Errorf("persist runtime config with cookie migration: %w", err) + } + if migratedCount == 0 || len(failedCookies) > 0 { + return nil + } + if err := cookies.DeleteMigratedCookieFiles(cookiesDir, logger); err != nil { + logger.Warnf("cookies: migration cleanup failed dir=%s migrated=%d: %v", cookiesDir, migratedCount, err) + } + return nil +} + +func validateRuntimeCookieAuth(dbPath string) error { + material, err := authmaterial.LoadFromDBPath(dbPath) + if err != nil { + if errors.Is(err, authmaterial.ErrUnavailable) { + return cookies.ErrAuthHelperUnavailable + } + return fmt.Errorf("load auth helper: %w", err) + } + if _, _, err := material.PrimaryHelper(); err != nil { + if errors.Is(err, authmaterial.ErrUnavailable) { + return cookies.ErrAuthHelperUnavailable + } + return fmt.Errorf("derive auth helper: %w", err) + } + return nil +} diff --git a/internal/webserver/runtime_activation_test.go b/internal/webserver/runtime_activation_test.go new file mode 100644 index 00000000..ac6e683f --- /dev/null +++ b/internal/webserver/runtime_activation_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "context" + "errors" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/services/db" + "github.com/autobrr/upbrr/pkg/api" +) + +type activationTestInstaller struct { + mu sync.Mutex + rows *[]string + retired RetiredRuntime + generations []RuntimeGeneration +} + +func (i *activationTestInstaller) Install(generation RuntimeGeneration) RetiredRuntime { + i.mu.Lock() + defer i.mu.Unlock() + if i.rows != nil { + *i.rows = append(*i.rows, "install") + } + i.generations = append(i.generations, generation) + return i.retired +} + +type activationTestOwner struct { + closed atomic.Bool + onClose func() +} + +func (o *activationTestOwner) Close() error { + o.closed.Store(true) + if o.onClose != nil { + o.onClose() + } + return nil +} + +func TestRuntimeActivatorOwnsOrderedTransition(t *testing.T) { + t.Setenv("UA_DEFAULT_SCREENS", "3") + repo := openRuntimeActivationTestRepo(t) + rows := make([]string, 0, 5) + builtOwner := &activationTestOwner{onClose: func() { rows = append(rows, "built-close") }} + retiredOwner := &activationTestOwner{onClose: func() { rows = append(rows, "retired-close") }} + installer := &activationTestInstaller{ + rows: &rows, + retired: RetiredRuntime{Owner: retiredOwner}, + } + activator, err := NewRuntimeActivator(repo, repo.DBPath(), installer) + if err != nil { + t.Fatalf("new activator: %v", err) + } + + var stored config.Config + activator.deps = runtimeActivationDeps{ + build: func(_ context.Context, cfg config.Config, gotRepo *db.SQLiteRepository) (RuntimeGeneration, error) { + rows = append(rows, "build") + if gotRepo != repo { + t.Fatal("build received a different repository") + } + return RuntimeGeneration{Config: cfg, Owner: builtOwner}, nil + }, + cookies: func(_ context.Context, gotRepo *db.SQLiteRepository, dbPath string, _ api.Logger) error { + rows = append(rows, "cookies") + if gotRepo != repo || dbPath != repo.DBPath() { + t.Fatalf("cookie maintenance scope = (%p, %q), want (%p, %q)", gotRepo, dbPath, repo, repo.DBPath()) + } + return nil + }, + persist: func(_ context.Context, cfg *config.Config, gotRepo *db.SQLiteRepository, dbPath string, _ api.Logger) error { + rows = append(rows, "persist") + stored = *cfg + if gotRepo != repo || dbPath != repo.DBPath() { + t.Fatalf("persistence scope = (%p, %q), want (%p, %q)", gotRepo, dbPath, repo, repo.DBPath()) + } + return nil + }, + } + + candidate := validRuntimeActivationConfig() + candidate.MainSettings.DBPath = filepath.Join(t.TempDir(), "ignored.db") + if err := activator.Activate(context.Background(), candidate); err != nil { + t.Fatalf("activate: %v", err) + } + + wantRows := []string{"build", "cookies", "persist", "install", "retired-close"} + if len(rows) != len(wantRows) { + t.Fatalf("transition rows = %v, want %v", rows, wantRows) + } + for index := range wantRows { + if rows[index] != wantRows[index] { + t.Fatalf("transition rows = %v, want %v", rows, wantRows) + } + } + if builtOwner.closed.Load() { + t.Fatal("installed generation was retired") + } + if !retiredOwner.closed.Load() { + t.Fatal("replaced generation was not retired") + } + if stored.MainSettings.DBPath != repo.DBPath() || stored.ScreenshotHandling.Screens != 1 { + t.Fatalf("stored config = %#v", stored) + } + if len(installer.generations) != 1 { + t.Fatalf("installed generations = %d, want 1", len(installer.generations)) + } + installed := installer.generations[0].Config + if installed.MainSettings.DBPath != repo.DBPath() || installed.ScreenshotHandling.Screens != 3 { + t.Fatalf("installed runtime config = %#v", installed) + } + if candidate.MainSettings.DBPath == repo.DBPath() { + t.Fatal("activation mutated the caller's config") + } +} + +func TestRuntimeActivatorFailureStagesDoNotInstall(t *testing.T) { + tests := []struct { + name string + stage ActivationStage + configure func(*RuntimeActivator, *activationTestOwner) + }{ + { + name: "build", + stage: ActivationStageBuild, + configure: func(activator *RuntimeActivator, _ *activationTestOwner) { + activator.deps.build = func(context.Context, config.Config, *db.SQLiteRepository) (RuntimeGeneration, error) { + return RuntimeGeneration{}, errors.New("build failed") + } + }, + }, + { + name: "cookies", + stage: ActivationStageCookies, + configure: func(activator *RuntimeActivator, owner *activationTestOwner) { + activator.deps.build = activationTestBuild(owner) + activator.deps.cookies = func(context.Context, *db.SQLiteRepository, string, api.Logger) error { + return errors.New("cookies failed") + } + }, + }, + { + name: "persist", + stage: ActivationStagePersist, + configure: func(activator *RuntimeActivator, owner *activationTestOwner) { + activator.deps.build = activationTestBuild(owner) + activator.deps.cookies = activationTestCookies + activator.deps.persist = func(context.Context, *config.Config, *db.SQLiteRepository, string, api.Logger) error { + return errors.New("persist failed") + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := openRuntimeActivationTestRepo(t) + installer := &activationTestInstaller{} + activator, err := NewRuntimeActivator(repo, repo.DBPath(), installer) + if err != nil { + t.Fatalf("new activator: %v", err) + } + owner := &activationTestOwner{} + test.configure(activator, owner) + + err = activator.Activate(context.Background(), validRuntimeActivationConfig()) + assertActivationStage(t, err, test.stage) + if len(installer.generations) != 0 { + t.Fatal("failed activation installed a generation") + } + if test.stage != ActivationStageBuild && !owner.closed.Load() { + t.Fatal("failed activation did not close its built generation") + } + }) + } +} + +func TestRuntimeActivatorReportsStoredAndRuntimeValidationStages(t *testing.T) { + repo := openRuntimeActivationTestRepo(t) + installer := &activationTestInstaller{} + activator, err := NewRuntimeActivator(repo, repo.DBPath(), installer) + if err != nil { + t.Fatalf("new activator: %v", err) + } + + invalidStored := validRuntimeActivationConfig() + invalidStored.ScreenshotHandling.Screens = 0 + err = activator.Activate(context.Background(), invalidStored) + assertActivationStage(t, err, ActivationStageValidateStored) + + t.Setenv("UA_DEFAULT_SCREENS", "0") + err = activator.Activate(context.Background(), validRuntimeActivationConfig()) + assertActivationStage(t, err, ActivationStageValidateRuntime) + if len(installer.generations) != 0 { + t.Fatal("invalid config installed a generation") + } +} + +func TestRuntimeActivatorSerializesCompleteTransition(t *testing.T) { + repo := openRuntimeActivationTestRepo(t) + installer := &activationTestInstaller{} + activator, err := NewRuntimeActivator(repo, repo.DBPath(), installer) + if err != nil { + t.Fatalf("new activator: %v", err) + } + + entered := make(chan int, 2) + releaseFirst := make(chan struct{}) + var builds atomic.Int32 + activator.deps = runtimeActivationDeps{ + build: func(_ context.Context, cfg config.Config, _ *db.SQLiteRepository) (RuntimeGeneration, error) { + build := int(builds.Add(1)) + entered <- build + if build == 1 { + <-releaseFirst + } + return RuntimeGeneration{Config: cfg}, nil + }, + cookies: activationTestCookies, + persist: func(context.Context, *config.Config, *db.SQLiteRepository, string, api.Logger) error { return nil }, + } + + errs := make(chan error, 2) + go func() { errs <- activator.Activate(context.Background(), validRuntimeActivationConfig()) }() + if build := <-entered; build != 1 { + t.Fatalf("first build = %d", build) + } + go func() { errs <- activator.Activate(context.Background(), validRuntimeActivationConfig()) }() + select { + case build := <-entered: + t.Fatalf("second activation entered build %d before first transition completed", build) + case <-time.After(100 * time.Millisecond): + } + close(releaseFirst) + if build := <-entered; build != 2 { + t.Fatalf("second build = %d", build) + } + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("activate: %v", err) + } + } +} + +func activationTestBuild(owner LifecycleOwner) func(context.Context, config.Config, *db.SQLiteRepository) (RuntimeGeneration, error) { + return func(_ context.Context, cfg config.Config, _ *db.SQLiteRepository) (RuntimeGeneration, error) { + return RuntimeGeneration{Config: cfg, Owner: owner}, nil + } +} + +func activationTestCookies(context.Context, *db.SQLiteRepository, string, api.Logger) error { + return nil +} + +func assertActivationStage(t *testing.T, err error, want ActivationStage) { + t.Helper() + var activationErr *ActivationError + if !errors.As(err, &activationErr) { + t.Fatalf("error = %v, want ActivationError", err) + } + if activationErr.Stage != want { + t.Fatalf("stage = %q, want %q", activationErr.Stage, want) + } +} + +func validRuntimeActivationConfig() config.Config { + return config.Config{ + MainSettings: config.MainSettingsConfig{TMDBAPI: "x"}, + ScreenshotHandling: config.ScreenshotHandlingConfig{Screens: 1}, + Logging: config.LoggingConfig{Level: "error"}, + } +} + +func openRuntimeActivationTestRepo(t *testing.T) *db.SQLiteRepository { + t.Helper() + repo, err := db.OpenWithLogger(filepath.Join(t.TempDir(), "runtime-activation.db"), api.NopLogger{}) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + return repo +} diff --git a/internal/webserver/runtime_snapshot.go b/internal/webserver/runtime_snapshot.go index a91e420f..b85922ac 100644 --- a/internal/webserver/runtime_snapshot.go +++ b/internal/webserver/runtime_snapshot.go @@ -12,14 +12,20 @@ import ( "github.com/autobrr/upbrr/pkg/api" ) +// backendRuntimeSnapshot is a shallow, single-generation view of config and +// its bound runtime resources. It preserves request consistency without +// transferring ownership of the capability bundle, owner, or logger. type backendRuntimeSnapshot struct { - cfg config.Config - core api.Core - coreInitErr error - logger *logging.Logger + generationID uint64 + cfg config.Config + capabilities CoreCapabilities + coreOwner LifecycleOwner + coreInitErr error + logger *logging.Logger } -// runtimeSnapshot returns a consistent copy of runtime fields guarded by runtimeMu. +// runtimeSnapshot copies all runtime fields under one read lock so callers do +// not combine config and capabilities from different settings generations. func (b *Backend) runtimeSnapshot() backendRuntimeSnapshot { if b == nil { return backendRuntimeSnapshot{} @@ -27,10 +33,12 @@ func (b *Backend) runtimeSnapshot() backendRuntimeSnapshot { b.runtimeMu.RLock() defer b.runtimeMu.RUnlock() return backendRuntimeSnapshot{ - cfg: b.cfg, - core: b.core, - coreInitErr: b.coreInitErr, - logger: b.logger, + generationID: b.runtimeGeneration, + cfg: b.cfg, + capabilities: b.capabilities, + coreOwner: b.coreOwner, + coreInitErr: b.coreInitErr, + logger: b.logger, } } @@ -39,7 +47,7 @@ func (b *Backend) requireRuntime() (backendRuntimeSnapshot, error) { return backendRuntimeSnapshot{}, errors.New("backend not initialized") } rt := b.runtimeSnapshot() - if rt.core != nil { + if rt.capabilities.Available() { return rt, nil } if rt.coreInitErr != nil { @@ -58,14 +66,56 @@ func (b *Backend) currentConfig() config.Config { return b.cfg } -// currentCore returns the active core service under the runtime lock. -func (b *Backend) currentCore() api.Core { - if b == nil { - return nil +func requireBackendCapability[T any](capability T, name string) (T, error) { + if !CapabilityAvailable(capability) { + var zero T + return zero, fmt.Errorf("%s capability unavailable", name) } - b.runtimeMu.RLock() - defer b.runtimeMu.RUnlock() - return b.core + return capability, nil +} + +func (rt backendRuntimeSnapshot) metadataCore() (MetadataCapability, error) { + return requireBackendCapability(rt.capabilities.Metadata, "metadata") +} + +func (rt backendRuntimeSnapshot) releasePreparationCore() (ReleasePreparationCapability, error) { + return requireBackendCapability(rt.capabilities.ReleasePreparation, "release preparation") +} + +func (rt backendRuntimeSnapshot) selectionCore() (SelectionCapability, error) { + return requireBackendCapability(rt.capabilities.Selection, "blu-ray selection") +} + +func (rt backendRuntimeSnapshot) preparationCore() (PreparationCapability, error) { + return requireBackendCapability(rt.capabilities.Preparation, "preparation") +} + +func (rt backendRuntimeSnapshot) uploadReviewCore() (UploadReviewCapability, error) { + return requireBackendCapability(rt.capabilities.UploadReview, "upload review") +} + +func (rt backendRuntimeSnapshot) screenshotCore() (ScreenshotCapability, error) { + return requireBackendCapability(rt.capabilities.Screenshots, "screenshot") +} + +func (rt backendRuntimeSnapshot) hostedImageCore() (HostedImageCapability, error) { + return requireBackendCapability(rt.capabilities.HostedImages, "hosted image") +} + +func (rt backendRuntimeSnapshot) dvdMenuCore() (DVDCapability, error) { + return requireBackendCapability(rt.capabilities.DVD, "DVD menu") +} + +func (rt backendRuntimeSnapshot) descriptionCore() (DescriptionCapability, error) { + return requireBackendCapability(rt.capabilities.Description, "description") +} + +func (rt backendRuntimeSnapshot) playlistCore() (PlaylistCapability, error) { + return requireBackendCapability(rt.capabilities.Playlists, "playlist") +} + +func (rt backendRuntimeSnapshot) historyCore() (HistoryCapability, error) { + return requireBackendCapability(rt.capabilities.History, "history") } // currentLogger returns the active logger under the runtime lock. @@ -135,28 +185,78 @@ func (s *Server) logErrorf(format string, args ...any) { s.backend.logErrorf(format, args...) } -// baseUploadOptions returns upload options from the current runtime config. -func (b *Backend) baseUploadOptions() api.UploadOptions { - return buildBaseMetadataOptions(b.currentConfig()) -} - // baseUploadOptions returns upload options derived from the same runtime // snapshot as the core selected for a request. func (rt backendRuntimeSnapshot) baseUploadOptions() api.UploadOptions { return buildBaseMetadataOptions(rt.cfg) } -// replaceRuntime swaps all runtime-owned fields under one write lock and -// returns the previous core and logger for shutdown after callers finish -// follow-up work such as log stream rebinding. -func (b *Backend) replaceRuntime(cfg config.Config, core api.Core, logger *logging.Logger) (api.Core, *logging.Logger) { +// replaceRuntime swaps one complete runtime generation and returns the previous +// lifecycle owner and logger for separate shutdown after follow-up work such as +// log stream rebinding. +func (b *Backend) replaceRuntime( + cfg config.Config, + capabilities CoreCapabilities, + logger *logging.Logger, +) (LifecycleOwner, *logging.Logger) { + return b.replaceRuntimeGeneration(AllocateRuntimeGenerationID(), cfg, capabilities, nil, logger) +} + +func (b *Backend) replaceRuntimeGeneration( + generationID uint64, + cfg config.Config, + capabilities CoreCapabilities, + owner LifecycleOwner, + logger *logging.Logger, +) (LifecycleOwner, *logging.Logger) { b.runtimeMu.Lock() defer b.runtimeMu.Unlock() - oldCore := b.core + oldOwner := b.coreOwner oldLogger := b.logger - b.core = core + b.capabilities = capabilities + b.runtimeGeneration = generationID + b.coreOwner = owner b.coreInitErr = nil b.logger = logger b.cfg = cfg - return oldCore, oldLogger + return oldOwner, oldLogger +} + +type backendRuntimeInstaller struct { + backend *Backend +} + +func (i backendRuntimeInstaller) Install(generation RuntimeGeneration) RetiredRuntime { + oldOwner, oldLogger := i.backend.replaceRuntimeGeneration( + generation.ID, + generation.Config, + generation.Capabilities, + generation.Owner, + generation.Logger, + ) + if i.backend.hub != nil { + i.backend.hub.SetLogger(generation.Logger) + } + i.backend.rebindLogStreams(oldLogger, generation.Logger) + return RetiredRuntime{Owner: oldOwner, Logger: oldLogger} +} + +func (b *Backend) runtimeActivator() (*RuntimeActivator, error) { + if b == nil { + return nil, errors.New("backend not initialized") + } + b.activationInitMu.Lock() + defer b.activationInitMu.Unlock() + if b.activator != nil { + return b.activator, nil + } + if b.repo == nil { + return nil, errors.New("config repository not initialized") + } + activator, err := NewRuntimeActivator(b.repo, b.repo.DBPath(), backendRuntimeInstaller{backend: b}) + if err != nil { + return nil, fmt.Errorf("initialize runtime activator: %w", err) + } + b.activator = activator + return activator, nil } diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 167760a8..6e01d9bb 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -24,7 +24,6 @@ import ( "github.com/pkg/browser" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/guiapp" "github.com/autobrr/upbrr/internal/redaction" ) @@ -45,7 +44,6 @@ type Server struct { cfg config.Config cliCfg CLIConfig backend *Backend - picker nativePicker auth *authStore sessions *sessionManager hub *eventHub @@ -101,6 +99,7 @@ func New(opts Options) (*Server, error) { if err != nil { return nil, err } + sessions.SetRemovedCallback(backend.removeJobOwner) sessionsClosed := false defer func() { if !sessionsClosed { @@ -111,7 +110,6 @@ func New(opts Options) (*Server, error) { cfg: cfg, cliCfg: cliCfg, backend: backend, - picker: newNativePicker(), auth: authStore, sessions: sessions, hub: hub, @@ -387,14 +385,14 @@ func unwrapBrowserIPv6Host(host string) (string, bool) { } func resolveWebAssets() (fs.FS, error) { - assets, err := guiapp.ResolveAssets(nil) + assets, err := resolveAssets(nil) if err == nil { return assets, nil } - // Keep the legacy repo-local fallback so local development can still serve - // generated assets even if embedding was skipped for some reason. - distPath := filepath.Join("gui", "frontend", "dist") + // Keep the repo-local fallback so local development can still serve + // generated assets when embedding was skipped. + distPath := filepath.Join("webui", "dist") if stat, statErr := os.Stat(filepath.Join(distPath, "index.html")); statErr == nil && !stat.IsDir() { return os.DirFS(distPath), nil } diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go index d913e869..fcf6538f 100644 --- a/internal/webserver/server_test.go +++ b/internal/webserver/server_test.go @@ -90,8 +90,8 @@ func TestNewClosesBackendWhenAssetSetupFails(t *testing.T) { closed := false newBackendWithContextForServer = func(_ context.Context, cfg config.Config, _ *eventHub) (*Backend, error) { return &Backend{ - cfg: cfg, - core: &serverCloseCore{closed: &closed}, + cfg: cfg, + coreOwner: &serverCloseCore{closed: &closed}, }, nil } resolveWebAssetsForServer = func() (fs.FS, error) { @@ -296,7 +296,7 @@ func TestServerLoggerAccessSynchronizedDuringRuntimeReplacement(t *testing.T) { return } logger.SetConsoleOutput(io.Discard, io.Discard) - _, oldLogger := backend.replaceRuntime(config.Config{}, nil, logger) + _, oldLogger := backend.replaceRuntime(config.Config{}, CoreCapabilities{}, logger) if oldLogger != nil { _ = oldLogger.Close() } @@ -340,9 +340,21 @@ func TestBaseURLPreservesExplicitBaseURL(t *testing.T) { baseURL string want string }{ - {name: "path trailing slash", baseURL: " https://example.test/upbrr/ ", want: "https://example.test/upbrr/"}, - {name: "root slash", baseURL: "https://example.test/", want: "https://example.test/"}, - {name: "query fragment stripped", baseURL: "https://example.test/upbrr/?token=secret#frag", want: "https://example.test/upbrr/"}, + { + name: "path trailing slash", + baseURL: " https://example.test/upbrr/ ", + want: "https://example.test/upbrr/", + }, + { + name: "root slash", + baseURL: "https://example.test/", + want: "https://example.test/", + }, + { + name: "query fragment stripped", + baseURL: "https://example.test/upbrr/?token=secret#frag", + want: "https://example.test/upbrr/", + }, } for _, tc := range cases { @@ -372,12 +384,42 @@ func TestBaseURLSynthesizesLocalOriginForPathOnlyBaseURL(t *testing.T) { baseURL string want string }{ - {name: "wildcard ipv4", host: "0.0.0.0", baseURL: " /upbrr/ ", want: "http://localhost:49152/upbrr/"}, - {name: "wildcard ipv6", host: "::", baseURL: "upbrr", want: "http://localhost:49152/upbrr/"}, - {name: "bracketed wildcard ipv6", host: "[::]", baseURL: "/tools/upbrr", want: "http://localhost:49152/tools/upbrr/"}, - {name: "scoped ipv6", host: "fe80::1%zone", baseURL: "/upbrr/", want: "http://[fe80::1%25zone]:49152/upbrr/"}, - {name: "query and fragment dropped", host: "127.0.0.1", baseURL: "/upbrr/?token=secret#frag", want: "http://127.0.0.1:49152/upbrr/"}, - {name: "root path", host: "127.0.0.1", baseURL: "/", want: "http://127.0.0.1:49152"}, + { + name: "wildcard ipv4", + host: "0.0.0.0", + baseURL: " /upbrr/ ", + want: "http://localhost:49152/upbrr/", + }, + { + name: "wildcard ipv6", + host: "::", + baseURL: "upbrr", + want: "http://localhost:49152/upbrr/", + }, + { + name: "bracketed wildcard ipv6", + host: "[::]", + baseURL: "/tools/upbrr", + want: "http://localhost:49152/tools/upbrr/", + }, + { + name: "scoped ipv6", + host: "fe80::1%zone", + baseURL: "/upbrr/", + want: "http://[fe80::1%25zone]:49152/upbrr/", + }, + { + name: "query and fragment dropped", + host: "127.0.0.1", + baseURL: "/upbrr/?token=secret#frag", + want: "http://127.0.0.1:49152/upbrr/", + }, + { + name: "root path", + host: "127.0.0.1", + baseURL: "/", + want: "http://127.0.0.1:49152", + }, } for _, tc := range cases { @@ -446,7 +488,11 @@ func TestBaseURLEscapesScopedIPv6Zone(t *testing.T) { Port: 7480, }, } - got := server.baseURL(&net.TCPAddr{IP: net.ParseIP("fe80::1"), Port: 49152, Zone: "zone"}) + got := server.baseURL(&net.TCPAddr{ + IP: net.ParseIP("fe80::1"), + Port: 49152, + Zone: "zone", + }) if got != tc.want { t.Fatalf("baseURL() = %q, want %q", got, tc.want) } diff --git a/internal/webserver/shutdown_test.go b/internal/webserver/shutdown_test.go index d19f1dcb..e06c51d9 100644 --- a/internal/webserver/shutdown_test.go +++ b/internal/webserver/shutdown_test.go @@ -11,26 +11,32 @@ import ( "net/http/httptest" "testing" "time" + + sharedjobs "github.com/autobrr/upbrr/internal/webserver/jobs" ) func TestStopAllDupeJobsWaitsForWorkers(t *testing.T) { - backend := &Backend{ - dupes: make(map[string]*dupeCheckJob), - } - + backend := &Backend{jobEngine: sharedjobs.New(nil, sharedjobs.Config{})} released := make(chan struct{}) - finished := make(chan struct{}) - backend.dupeWG.Add(1) - backend.dupes["job-1"] = &dupeCheckJob{ - id: "job-1", - cancel: func() { - go func() { - <-released - backend.dupeWG.Done() - close(finished) - }() + canceled := make(chan struct{}, 1) + started := make(chan struct{}, 1) + owner, err := backend.ensureJobOwner("session-a") + if err != nil { + t.Fatalf("ensure owner: %v", err) + } + _, err = backend.jobEngine.StartDupe(context.Background(), owner, sharedjobs.DupeSpec{ + CorrelationID: "shutdown-dupe", + Snapshot: webTestDupeSnapshot("Example.Release.2026.1080p-GRP"), + Runner: blockingDupeRunner{ + started: started, + canceled: canceled, + release: released, }, + }) + if err != nil { + t.Fatalf("start dupe: %v", err) } + <-started done := make(chan struct{}) go func() { @@ -43,6 +49,11 @@ func TestStopAllDupeJobsWaitsForWorkers(t *testing.T) { t.Fatal("expected stopAllDupeJobs to wait for active workers") case <-time.After(50 * time.Millisecond): } + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("expected shutdown to cancel dupe worker") + } close(released) @@ -51,28 +62,30 @@ func TestStopAllDupeJobsWaitsForWorkers(t *testing.T) { case <-time.After(250 * time.Millisecond): t.Fatal("expected stopAllDupeJobs to return after workers finish") } - - <-finished } func TestStopAllUploadJobsWaitsForWorkers(t *testing.T) { - backend := &Backend{ - uploads: make(map[string]*trackerUploadJob), - } - + backend := &Backend{jobEngine: sharedjobs.New(nil, sharedjobs.Config{})} released := make(chan struct{}) - finished := make(chan struct{}) - backend.uploadWG.Add(1) - backend.uploads["job-1"] = &trackerUploadJob{ - id: "job-1", - cancel: func() { - go func() { - <-released - backend.uploadWG.Done() - close(finished) - }() + canceled := make(chan struct{}, 1) + started := make(chan struct{}, 1) + owner, err := backend.ensureJobOwner("session-a") + if err != nil { + t.Fatalf("ensure owner: %v", err) + } + _, err = backend.jobEngine.StartUpload(context.Background(), owner, sharedjobs.UploadSpec{ + CorrelationID: "shutdown-upload", + Snapshot: webTestUploadSnapshot("Example.Release.2026.1080p-GRP"), + Runner: blockingUploadRunner{ + started: started, + canceled: canceled, + release: released, }, + }) + if err != nil { + t.Fatalf("start upload: %v", err) } + <-started done := make(chan struct{}) go func() { @@ -85,6 +98,11 @@ func TestStopAllUploadJobsWaitsForWorkers(t *testing.T) { t.Fatal("expected stopAllUploadJobs to wait for active workers") case <-time.After(50 * time.Millisecond): } + select { + case <-canceled: + case <-time.After(time.Second): + t.Fatal("expected shutdown to cancel upload worker") + } close(released) @@ -93,8 +111,6 @@ func TestStopAllUploadJobsWaitsForWorkers(t *testing.T) { case <-time.After(250 * time.Millisecond): t.Fatal("expected stopAllUploadJobs to return after workers finish") } - - <-finished } func TestStopAllLogStreamsWaitsForWorkers(t *testing.T) { diff --git a/internal/webserver/tracker_auth_import_test.go b/internal/webserver/tracker_auth_import_test.go index 4c878293..c2a6a6ab 100644 --- a/internal/webserver/tracker_auth_import_test.go +++ b/internal/webserver/tracker_auth_import_test.go @@ -13,7 +13,7 @@ import ( "github.com/autobrr/upbrr/internal/authmaterial" "github.com/autobrr/upbrr/internal/config" - "github.com/autobrr/upbrr/internal/trackerauth" + trackerauth "github.com/autobrr/upbrr/internal/trackers/auth" ) func TestBackendImportTrackerAuthCookieContentRejectsOverCap(t *testing.T) { diff --git a/internal/webserver/tracker_catalog_test.go b/internal/webserver/tracker_catalog_test.go new file mode 100644 index 00000000..c132322e --- /dev/null +++ b/internal/webserver/tracker_catalog_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package webserver + +import ( + "slices" + "testing" + + "github.com/autobrr/upbrr/internal/config" + "github.com/autobrr/upbrr/internal/trackers" + "github.com/autobrr/upbrr/pkg/api" +) + +func TestListTrackerCatalogComposesIdentitySchemaAndConfiguredState(t *testing.T) { + t.Parallel() + + backend := &Backend{cfg: config.Config{Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ + "RHD": {APIKey: "configured"}, + "BTN": {Username: "partial"}, + "AITHER": {ImageHost: "imgbox"}, + "RETIRED": {Unknown: map[string]any{"keep_me": "retained"}}, + }}}} + + catalog, err := backend.ListTrackerCatalog() + if err != nil { + t.Fatalf("ListTrackerCatalog: %v", err) + } + if !slices.Equal(catalog.Unsupported, []string{"RETIRED"}) { + t.Fatalf("unsupported = %#v", catalog.Unsupported) + } + + rhd := catalogEntryByName(t, catalog.Entries, "RHD") + if !rhd.Configured || rhd.Family != string(trackers.FamilyUnit3D) || rhd.BaseURL == "" || + rhd.UploadContentMode != string(trackers.UploadContentModeDescription) { + t.Fatalf("RHD catalog entry = %#v", rhd) + } + if mode := catalogEntryByName(t, catalog.Entries, "BTN").UploadContentMode; mode != string(trackers.UploadContentModeNone) { + t.Fatalf("BTN upload content mode = %q", mode) + } + if mode := catalogEntryByName(t, catalog.Entries, "ANT").UploadContentMode; mode != string(trackers.UploadContentModeScreenshots) { + t.Fatalf("ANT upload content mode = %q", mode) + } + if !catalogEntryByName(t, catalog.Entries, "BTN").Configured { + t.Fatal("partial BTN credentials should count as configured") + } + if catalogEntryByName(t, catalog.Entries, "AITHER").Configured { + t.Fatal("optional AITHER image host should not count as configured") + } + + schemas, err := config.OrderedTrackerSchemas() + if err != nil { + t.Fatalf("OrderedTrackerSchemas: %v", err) + } + rhdSchema := slices.IndexFunc(schemas, func(schema config.TrackerSchema) bool { return schema.Name == "RHD" }) + if rhdSchema < 0 { + t.Fatal("RHD schema missing") + } + wantKeys := make([]string, len(schemas[rhdSchema].Fields)) + for index, field := range schemas[rhdSchema].Fields { + wantKeys[index] = field.JSONKey + } + gotKeys := make([]string, len(rhd.Fields)) + for index, field := range rhd.Fields { + gotKeys[index] = field.Key + } + if !slices.Equal(gotKeys, wantKeys) { + t.Fatalf("RHD field order = %#v, want %#v", gotKeys, wantKeys) + } +} + +func catalogEntryByName(t *testing.T, entries []api.TrackerCatalogEntry, name string) api.TrackerCatalogEntry { + t.Helper() + index := slices.IndexFunc(entries, func(entry api.TrackerCatalogEntry) bool { return entry.Name == name }) + if index < 0 { + t.Fatalf("catalog entry %s missing", name) + } + return entries[index] +} diff --git a/internal/webserver/tracker_icons.go b/internal/webserver/tracker_icons.go index 6d26a504..589cad41 100644 --- a/internal/webserver/tracker_icons.go +++ b/internal/webserver/tracker_icons.go @@ -5,13 +5,34 @@ package webserver import ( "encoding/base64" + "fmt" "net/http" "strings" - "github.com/autobrr/upbrr/internal/config" "github.com/autobrr/upbrr/internal/services/trackericon" + trackerimpl "github.com/autobrr/upbrr/internal/trackers/impl" ) +// resolveTrackerIconTarget accepts only registered trackers and falls back to +// the registry-owned base URL when no explicit favicon URL is supplied. +func resolveTrackerIconTarget(trackerName string, faviconURL string) (string, string, error) { + registry, err := trackerimpl.NewRegistry() + if err != nil { + return "", "", fmt.Errorf("tracker icon: build registry: %w", err) + } + descriptor, ok := registry.LookupDescriptor(trackerName) + if !ok { + return "", "", fmt.Errorf("tracker icon: unsupported tracker %q", strings.TrimSpace(trackerName)) + } + urlToUse := strings.TrimSpace(faviconURL) + if urlToUse == "" { + urlToUse = descriptor.BaseURL + } + return descriptor.Name, urlToUse, nil +} + +// handleTrackerIcon serves decoded tracker icon bytes only for an allowlisted +// image MIME type and disables browser content sniffing. func (s *Server) handleTrackerIcon(w http.ResponseWriter, r *http.Request, _ session) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -31,10 +52,10 @@ func (s *Server) handleTrackerIcon(w http.ResponseWriter, r *http.Request, _ ses if s.backend != nil { cfg = s.backend.currentConfig() } - domain, resolvedURL := config.ResolveTrackerDomain(&cfg, strings.TrimSpace(req.Domain)) - urlToUse := strings.TrimSpace(req.URL) - if urlToUse == "" { - urlToUse = resolvedURL + domain, urlToUse, err := resolveTrackerIconTarget(req.Domain, req.URL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } sanitized := trackericon.SafeDomainFilename(domain) diff --git a/internal/webserver/tracker_icons_test.go b/internal/webserver/tracker_icons_test.go index 40aa0cfc..06b4bf8f 100644 --- a/internal/webserver/tracker_icons_test.go +++ b/internal/webserver/tracker_icons_test.go @@ -36,22 +36,16 @@ func TestHandleTrackerIconUsesBackendRuntimeConfig(t *testing.T) { newDBPath := filepath.Join(t.TempDir(), "new.sqlite") oldCfg := config.Config{ MainSettings: config.MainSettingsConfig{DBPath: oldDBPath}, - Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ - "TEST": {URL: "https://old.example"}, - }}, } newCfg := config.Config{ MainSettings: config.MainSettingsConfig{DBPath: newDBPath}, - Trackers: config.TrackersConfig{Trackers: map[string]config.TrackerConfig{ - "TEST": {URL: "https://new.example"}, - }}, } iconDir, err := db.Subdir(newDBPath, "tracker-icons") if err != nil { t.Fatalf("create icon dir: %v", err) } - iconPath := filepath.Join(iconDir, customTrackerIconCacheName("new.example", "https://new.example")) + iconPath := filepath.Join(iconDir, customTrackerIconCacheName("AITHER", "https://aither.cc")) if err := os.WriteFile(iconPath, trackerIconTestPNG, 0o600); err != nil { t.Fatalf("write cached icon: %v", err) } @@ -60,7 +54,7 @@ func TestHandleTrackerIconUsesBackendRuntimeConfig(t *testing.T) { cfg: oldCfg, backend: &Backend{cfg: newCfg}, } - req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/TrackerIcon", strings.NewReader(`{"Domain":"TEST"}`)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/app/TrackerIcon", strings.NewReader(`{"Domain":"AITHER"}`)) recorder := httptest.NewRecorder() server.handleTrackerIcon(recorder, req, session{}) diff --git a/lefthook.yml b/lefthook.yml index d63342ca..7f78a8d2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,7 +2,7 @@ min_version: 2.1.0 # Skip hooks with `git commit --no-verify` or `LEFTHOOK=0 git commit ...`. # Go-only contributors: `go install github.com/evilmartians/lefthook/v2@v2.1.0 && lefthook install`. -# Frontend/full-stack contributors: `pnpm install` in gui/frontend/ plus `lefthook install`. +# Frontend/full-stack contributors: `pnpm install` in webui/ plus `lefthook install`. # Execution model: # - Top-level jobs (frontend, go) run in parallel — they touch disjoint file sets. @@ -16,12 +16,15 @@ pre-commit: - rebase jobs: - name: frontend - root: "gui/frontend/" + root: "webui/" group: piped: true jobs: - name: format glob: "**/*.{ts,tsx,css,scss,json,md,html,yml,yaml}" + exclude: + - "webui/CLAUDE.md" + - "webui/e2e/CLAUDE.md" run: pnpm exec prettier --write --cache --ignore-unknown {staged_files} stage_fixed: true fail_text: "Prettier failed. Fix the errors above or bypass with --no-verify." @@ -29,7 +32,7 @@ pre-commit: - name: lint glob: "**/*.{ts,tsx}" run: pnpm exec eslint --cache --no-error-on-unmatched-pattern {staged_files} - fail_text: "ESLint failed. Fix errors above, or run `pnpm --dir gui/frontend run lint` locally." + fail_text: "ESLint failed. Fix errors above, or run `pnpm --dir webui run lint` locally." - name: go group: @@ -41,6 +44,11 @@ pre-commit: stage_fixed: true fail_text: "golangci-lint fmt failed. See output above." + - name: literalpolicy + glob: "**/*.go" + run: go run ./cmd/literalpolicy + fail_text: "Composite literal layout failed. Run `make fmt-go`." + - name: logpolicy glob: - "internal/*.go" @@ -57,10 +65,10 @@ pre-push: parallel: true jobs: - name: frontend-typecheck - root: "gui/frontend/" + root: "webui/" glob: "**/*.{ts,tsx}" run: pnpm run typecheck - fail_text: "TypeScript typecheck failed. Run `pnpm --dir gui/frontend run typecheck`." + fail_text: "TypeScript typecheck failed. Run `pnpm --dir webui run typecheck`." - name: go-lint glob: "**/*.go" diff --git a/pkg/api/AGENTS.md b/pkg/api/AGENTS.md index 63691d69..1366333f 100644 --- a/pkg/api/AGENTS.md +++ b/pkg/api/AGENTS.md @@ -6,11 +6,10 @@ Scoped rules for shared API contracts under `pkg/api`. Root and `internal/AGENTS - CLI: `cmd/upbrr` - Core orchestration: `internal/core` -- Wails backend: `internal/guiapp` -- Embedded web server/API: `internal/webserver` -- Frontend bridge: `gui/frontend/src` +- WebUI server/API: `internal/webserver` +- Browser clients/types: `webui/src` -Preserve CLI, Wails GUI, and embedded web parity unless intentionally changing an entrypoint. +Preserve CLI and WebUI behavior unless intentionally changing an entrypoint. ## Contract Changes @@ -18,19 +17,29 @@ Changes to these require entrypoint parity review: - `Request` - `UploadOptions` -- `PreparedMetadata` +- `PreparedRelease`, `PrepareResult`, and grouped canonical facts +- operation-specific inputs and exact `ReleaseRef` generations - dry-run/upload review payloads - questionnaire answers - description groups - tracker overrides and retry/skip flags - upload status/history rows +- `PreparationProgressUpdate` and `ImageUploadProgressUpdate` +- `OperationFailure` codes and recovery metadata -Check CLI builders, Wails methods, web `/api/app/*` routes, frontend bridge request shapes, and TS types. +Check CLI builders, WebUI `/api/app/*` routes, browser request shapes, and TS types. + +## Canonical / Presentation Boundaries + +- Canonical request and preparation contracts are single-source and operation inputs reference one exact `ReleaseRef` generation. +- Keep prepared facts typed and reusable; do not add presentation correlation or workflow state to canonical inputs or facts. +- WebUI transport injects progress correlation IDs and timestamps. Progress payloads must be frontend-safe; image-host updates are absolute snapshots for one host attempt. +- Recovery decisions use structured `OperationFailure` codes/metadata, never error-message substring matching. ## Checks - CLI/core contracts: `go test -race -v -timeout 20m ./cmd/upbrr ./internal/core ./pkg/api`. -- Wails/web API contracts: `go test -race -v -timeout 20m ./internal/guiapp ./internal/webserver ./internal/guishared ./pkg/api`. -- Frontend bridge/types: `pnpm --dir gui/frontend run typecheck` and `pnpm --dir gui/frontend run test:unit`. +- WebUI API contracts: `go test -race -v -timeout 20m ./internal/webserver/... ./pkg/api`. +- Browser clients/types: `pnpm --dir webui run typecheck` and `pnpm --dir webui run test:unit`. - Embedded runtime/UI behavior: build frontend, sync embedded assets, rebuild CLI, then inspect embedded web on `http://localhost:7480`. - Full shared-regression sweep when behavior crosses several entrypoints: `make test-go` plus relevant frontend checks. diff --git a/pkg/api/application_info.go b/pkg/api/application_info.go index b3b82a33..3b761228 100644 --- a/pkg/api/application_info.go +++ b/pkg/api/application_info.go @@ -13,7 +13,7 @@ import ( ) // ApplicationInfo describes the running build and optional runtime capability -// probes returned by Wails and embedded web entrypoints. +// probes returned by WebUI entrypoints. type ApplicationInfo struct { Version string `json:"version"` BuildIdentifier string `json:"buildIdentifier"` diff --git a/pkg/api/core.go b/pkg/api/core.go index 6673582b..d8f5fbcf 100644 --- a/pkg/api/core.go +++ b/pkg/api/core.go @@ -3,55 +3,36 @@ package api -import ( - "context" -) - -// Mode identifies the entrypoint building a core request. -type Mode string - -const ( - // ModeCLI marks requests started from the command-line entrypoint. - ModeCLI Mode = "cli" - // ModeGUI marks requests started from GUI or embedded web entrypoints. - ModeGUI Mode = "gui" -) - -// Request carries one core operation across CLI, Wails, and embedded web. -// Paths and path-keyed maps use host filesystem source paths as keys. +// Request carries one core operation across CLI and WebUI. type Request struct { - Paths []string - Mode Mode - Options UploadOptions - Execution ExecutionOptions - DescriptionGroups []DescriptionBuilderGroup - Trackers []string - TrackersRemove []string - IgnoreTrackerRuleFailures bool - IgnoreTrackerRuleFailuresFor []string - IgnoreDupesFor []string - SkipDupeCheck bool - SkipDupeAsActual bool - DoubleDupeCheck bool - SourceLookupURL string - DescriptionOverrideRaw string - DescriptionOverrideURL string - DescriptionOverrideGroup string - MetadataOverrides MetadataOverrides - TrackerConfigOverrides TrackerConfigOverrides - TrackerSiteOverrides TrackerSiteOverrides - ClientOverrides ClientOverrides - ImageHostOverrides ImageHostOverrides - ScreenshotOverrides ScreenshotOverrides - TorrentOverrides TorrentOverrides - TrackerIDOverrides map[string]string - ExternalIDOverrides ExternalIDOverrides - ExternalIDSelections map[string]ExternalIDSelection // keyed by source path, value is selected external IDs for that path - ReleaseNameOverrides ReleaseNameOverrides - TrackerQuestionnaireAnswers map[string]map[string]string // keyed by tracker, then questionnaire field key - PlaylistSelections map[string][]string // keyed by source path, value is selected playlist files - PlaylistSelectionsUseAll map[string]bool // keyed by source path, value is use-all flag - ConfirmBDMVRescan bool + SourcePath string + Options UploadOptions + Execution ExecutionOptions + DescriptionGroups []DescriptionBuilderGroup + Trackers []string + TrackersRemove []string + RuleAuthorizations []RuleAuthorization + IgnoreDupesFor []string + SkipDupeCheck bool + SkipDupeAsActual bool + DoubleDupeCheck bool + SourceLookupURL string + DescriptionOverrideRaw string + DescriptionOverrideURL string + DescriptionOverrideGroup string + MetadataOverrides MetadataOverrides + TrackerConfigOverrides TrackerConfigOverrides + TrackerSiteOverrides TrackerSiteOverrides + ClientOverrides ClientOverrides + ImageHostOverrides ImageHostOverrides + ScreenshotOverrides ScreenshotOverrides + TorrentOverrides TorrentOverrides + TrackerIDOverrides map[string]string + ExternalIDOverrides ExternalIDOverrides + ReleaseNameOverrides ReleaseNameOverrides + TrackerQuestionnaireAnswers map[string]map[string]string // keyed by tracker, then questionnaire field key + PlaylistInstruction PlaylistInstruction + ConfirmBDMVRescan bool } // ExecutionOptions controls queued and site-check execution behavior. @@ -62,23 +43,9 @@ type ExecutionOptions struct { SiteUploadTracker string } -// ExternalIDSelection records per-source-path user ID choices copied across -// resolved CLI/UI paths. Nil provider fields mean no user choice; non-nil -// fields follow [ExternalIDOverrides] positive-value and zero-clear semantics. -type ExternalIDSelection struct { - TMDBID *int - IMDBID *int - TVDBID *int - TVmazeID *int - // MALID stores user selection for the canonical MAL/AniList-compatible - // anime identifier for this source path. - MALID *int -} - -// UploadOptions contains per-run upload and preview behavior flags. +// UploadOptions contains per-run preparation and upload choices. The invoked +// operation, rather than a mode flag, determines whether trackers are submitted. type UploadOptions struct { - Debug bool - DryRun bool RunLogLevel string Screens int NoSeed bool @@ -117,60 +84,19 @@ type Result struct { UploadedCount int } -// Core defines the shared upload, preview, history, screenshot, and description -// operations used by CLI, Wails, and embedded web entrypoints. -type Core interface { - RunUpload(ctx context.Context, req Request) (Result, error) - RunUploadPrepared(ctx context.Context, req Request) (Result, error) - FetchMetadataPreview(ctx context.Context, req Request) (MetadataPreview, error) - // FetchDescriptionBuilderPreview returns editable description groups for one - // source path. Non-empty request trackers limit group generation to that set. - FetchDescriptionBuilderPreview(ctx context.Context, req Request) (DescriptionBuilderPreview, error) - // FetchDescriptionBuilderGroupPreview rebuilds one editable description group - // for the requested source path and selected tracker set. - FetchDescriptionBuilderGroupPreview(ctx context.Context, req Request) (DescriptionBuilderGroup, error) - // FetchPreparationPreview returns tracker preparation details for one source - // path. Non-empty request trackers limit preparation to that set. - FetchPreparationPreview(ctx context.Context, req Request) (PreparationPreview, error) - FetchTrackerDryRunPreview(ctx context.Context, req Request) (TrackerDryRunPreview, error) - CheckDupes(ctx context.Context, req Request) (DupeCheckSummary, error) - BuildUploadReview(ctx context.Context, req Request) (UploadReview, error) - FetchScreenshotPlan(ctx context.Context, req Request) (ScreenshotPlan, error) - GenerateScreenshots(ctx context.Context, req Request, selections []ScreenshotSelection, purpose ScreenshotPurpose) (ScreenshotResult, error) - PreviewScreenshotFrame(ctx context.Context, req Request, timestampSeconds float64) (ScreenshotPreview, error) - DeleteScreenshot(ctx context.Context, req Request, imagePath string) error - DeleteTrackerImageURL(ctx context.Context, req Request, url string) error - SaveFinalScreenshotSelections(ctx context.Context, req Request, images []ScreenshotImage) error - // CaptureDVDMenus captures and persists bounded automatic menu images for one prepared DVD. - CaptureDVDMenus(ctx context.Context, req Request) (DVDMenuCaptureResult, error) - // ListDVDMenuScreenshots lists persisted manual and automatic menu images for one prepared source. - ListDVDMenuScreenshots(ctx context.Context, req Request) ([]ScreenshotImage, error) - // DeleteDVDMenuScreenshot removes one managed menu image and its local references. - DeleteDVDMenuScreenshot(ctx context.Context, req Request, imagePath string) error - ListUploadCandidates(ctx context.Context, req Request) ([]ScreenshotImage, error) - ListUploadedImages(ctx context.Context, req Request) ([]UploadedImageLink, error) - UploadImages(ctx context.Context, req Request, host string, images []ScreenshotImage) (UploadImagesResult, error) - DeleteUploadedImage(ctx context.Context, req Request, imagePath string, host string) error - ImportMenuImages(ctx context.Context, req Request, paths []string) error - DiscoverPlaylists(ctx context.Context, sourcePath string) ([]PlaylistInfo, error) - SavePlaylistSelection(ctx context.Context, sourcePath string, playlists []string, useAll bool) error - LoadPlaylistSelection(ctx context.Context, sourcePath string) (PlaylistSelection, error) - ListHistory(ctx context.Context) ([]HistoryEntry, error) - GetHistoryOverview(ctx context.Context, sourcePath string) (HistoryOverview, error) - DeleteHistoryRelease(ctx context.Context, sourcePath string) error - DeleteAllHistoryReleases(ctx context.Context) (int, error) - RenderDescription(ctx context.Context, raw string) (string, error) - SaveDescriptionOverride(ctx context.Context, req Request, raw string) (DescriptionBuilderGroup, error) - Close() error -} - // Config defines the minimum application configuration contract required by core wiring. // Keeping this in pkg/api avoids leaking internal package types into exported APIs. type Config interface { Validate() error } -// CoreDependencies supplies the externally owned services used to construct a Core. +// RepositoryOwner owns repository lifecycle separately from borrowed +// capabilities. Core never closes an externally supplied owner. +type RepositoryOwner interface { + Close() error +} + +// CoreDependencies supplies the externally owned services used to construct the core runtime. type CoreDependencies struct { // Config is validated before the core is created. Config Config @@ -178,15 +104,13 @@ type CoreDependencies struct { Logger Logger // Services supplies optional service overrides; zero values use the core defaults. Services ServiceSet - // Repository stores metadata and history. Nil opens and owns a SQLite repository from Config. - Repository MetadataRepository + // Repository supplies immutable borrowed persistence capabilities. Its zero + // value opens and owns a SQLite repository from Config. + Repository RepositoryCapabilities + // RepositoryOwner identifies the externally owned adapter used to build + // Repository. It remains borrowed and is used only for owner-specific setup. + RepositoryOwner RepositoryOwner // SkipCookieMigration skips legacy cookie migration for callers that already // synchronize cookie encryption state with the shared repository. SkipCookieMigration bool } - -// CoreImpl is the exported concrete-core contract used by packages that accept -// any current core implementation. -type CoreImpl interface { - Core -} diff --git a/pkg/api/display.go b/pkg/api/display.go new file mode 100644 index 00000000..1f2310eb --- /dev/null +++ b/pkg/api/display.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +// PreparedReleaseDisplay is the canonical presentation projection for one +// exact prepared generation. +type PreparedReleaseDisplay struct { + ReleaseName string + Providers []ProviderDisplay +} + +// ProviderDisplay is one provider-local display projection. Details contains +// exactly one payload matching Provider. +type ProviderDisplay struct { + Provider IdentityProvider + ID int + DisplayID string + URL string + Provenance IdentityProvenance + SummaryAvailable bool + Summary ProviderDisplaySummary + Details ProviderDisplayDetails +} + +// ProviderDisplaySummary is the normalized provider presentation shared by +// CLI, WebUI preview, and history. +type ProviderDisplaySummary struct { + Title string + OriginalTitle string + Year int + Overview string + PosterURL string + BackdropURL string + Category string + Date string + EndDate string + OriginalLanguage string + MediaType string + RuntimeMinutes int + Genres string + Keywords string + TrailerURL string + Rating float64 + RatingCount int + Country string +} + +// ProviderDisplayDetails is a closed typed provider payload union. Projection +// validation guarantees exactly one non-nil field matching Provider. +type ProviderDisplayDetails struct { + TMDB *TMDBMetadata `json:",omitempty"` + IMDB *IMDBMetadata `json:",omitempty"` + TVDB *TVDBMetadata `json:",omitempty"` + TVmaze *TVmazeMetadata `json:",omitempty"` + AniList *AniListMetadata `json:",omitempty"` +} diff --git a/pkg/api/display_test.go b/pkg/api/display_test.go new file mode 100644 index 00000000..74c469b8 --- /dev/null +++ b/pkg/api/display_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestPreparedReleaseDisplayFixtureAndFrontendContract(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(filepath.Join("testdata", "prepared_release_display.json")) + if err != nil { + t.Fatal(err) + } + var display PreparedReleaseDisplay + if err := json.Unmarshal(data, &display); err != nil { + t.Fatal(err) + } + wantOrder := []IdentityProvider{ + IdentityProviderTMDB, + IdentityProviderIMDB, + IdentityProviderTVDB, + IdentityProviderTVmaze, + IdentityProviderMAL, + } + if len(display.Providers) != len(wantOrder) { + t.Fatalf("provider count = %d, want %d", len(display.Providers), len(wantOrder)) + } + for index, provider := range display.Providers { + if provider.Provider != wantOrder[index] { + t.Fatalf("provider[%d] = %q, want %q", index, provider.Provider, wantOrder[index]) + } + value := reflect.ValueOf(provider.Details) + nonNil := 0 + for _, field := range value.Fields() { + if !field.IsNil() { + nonNil++ + } + } + if nonNil != 1 { + t.Fatalf("provider %q detail variants = %d, want 1", provider.Provider, nonNil) + } + } + + frontendTypes := readRepoFile(t, "webui", "src", "types.ts") + for _, required := range []string{ + "export type PreparedReleaseDisplay = {", + "export type ProviderDisplaySummary = {", + `Provider: "tmdb"; Details: { TMDB: TMDBMetadata }`, + `Provider: "imdb"; Details: { IMDB: IMDBMetadata }`, + `Provider: "tvdb"; Details: { TVDB: TVDBMetadata }`, + `Provider: "tvmaze"; Details: { TVmaze: TVmazeMetadata }`, + `Provider: "mal"; Details: { AniList: AniListMetadata }`, + } { + if !strings.Contains(frontendTypes, required) { + t.Fatalf("frontend display contract missing %q", required) + } + } +} diff --git a/pkg/api/dupes.go b/pkg/api/dupes.go index 66790e17..26f32e5a 100644 --- a/pkg/api/dupes.go +++ b/pkg/api/dupes.go @@ -3,7 +3,10 @@ package api -import "time" +import ( + "strings" + "time" +) // DupeEntry describes one tracker duplicate-search hit before or after local // filtering. @@ -59,8 +62,7 @@ type DupeMatch struct { // DupeCheckResult is the duplicate-search outcome for one tracker. Raw contains // tracker results before filtering, Filtered contains blocking matches, and // skipped or failed checks carry Status plus SkipReason or Error. SkipCode is a -// stable machine-readable reason, while SkipRules names upload rules that caused -// rule-failure skips. +// stable machine-readable reason. type DupeCheckResult struct { Tracker string Raw []DupeEntry @@ -72,9 +74,7 @@ type DupeCheckResult struct { Skipped bool SkipReason string // SkipCode is a stable machine-readable skip reason. - SkipCode string - // SkipRules are upload rule keys that produced a rule-failure skip. - SkipRules []string + SkipCode string Status string Error string CheckedAt time.Time `ts_type:"string"` @@ -83,7 +83,67 @@ type DupeCheckResult struct { // DupeCheckSummary groups duplicate-search results for one prepared source // path. type DupeCheckSummary struct { - SourcePath string - Results []DupeCheckResult - Notes []string + SourcePath string + Results []DupeCheckResult + Notes []string + Eligibility TrackerEligibility +} + +// NewAcceptedDuplicateEvidence detaches one completed duplicate summary for +// transfer into an accepted dry-run plan. +func NewAcceptedDuplicateEvidence(release ReleaseRef, trackers []string, summary DupeCheckSummary) AcceptedDuplicateEvidence { + selected := make(map[string]struct{}, len(trackers)) + for _, tracker := range trackers { + if tracker = strings.ToUpper(strings.TrimSpace(tracker)); tracker != "" { + selected[tracker] = struct{}{} + } + } + results := make([]DupeCheckResult, 0, len(selected)) + for _, result := range summary.Results { + if _, ok := selected[strings.ToUpper(strings.TrimSpace(result.Tracker))]; ok { + results = append(results, result) + } + } + return AcceptedDuplicateEvidence{ + Release: release, + Trackers: append([]string(nil), trackers...), + Results: CloneDupeCheckResults(results), + } +} + +// Clone returns duplicate evidence detached from caller-owned slices. +func (e AcceptedDuplicateEvidence) Clone() AcceptedDuplicateEvidence { + e.Trackers = append([]string(nil), e.Trackers...) + e.Results = CloneDupeCheckResults(e.Results) + return e +} + +// CloneDupeCheckResults returns duplicate results with every nested mutable +// collection detached from its source. +func CloneDupeCheckResults(results []DupeCheckResult) []DupeCheckResult { + if results == nil { + return nil + } + cloned := make([]DupeCheckResult, len(results)) + for index, result := range results { + result.Raw = cloneDupeEntries(result.Raw) + result.Filtered = cloneDupeEntries(result.Filtered) + result.Match.MatchedEpisodeIDs = append([]DupeEpisodeMatch(nil), result.Match.MatchedEpisodeIDs...) + result.Notes = append([]string(nil), result.Notes...) + cloned[index] = result + } + return cloned +} + +func cloneDupeEntries(entries []DupeEntry) []DupeEntry { + if entries == nil { + return nil + } + cloned := make([]DupeEntry, len(entries)) + for index, entry := range entries { + entry.Files = append([]string(nil), entry.Files...) + entry.Flags = append([]string(nil), entry.Flags...) + cloned[index] = entry + } + return cloned } diff --git a/pkg/api/dupes_test.go b/pkg/api/dupes_test.go new file mode 100644 index 00000000..686b8690 --- /dev/null +++ b/pkg/api/dupes_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import "testing" + +func TestNewAcceptedDuplicateEvidenceFiltersSelectionAndDeepCopiesResults(t *testing.T) { + t.Parallel() + + summary := DupeCheckSummary{Results: []DupeCheckResult{ + { + Tracker: "BLU", + Status: "completed", + Raw: []DupeEntry{{Name: "Example.Release.2026.1080p-GRP", Files: []string{"video.mkv"}}}, + Match: DupeMatch{MatchedEpisodeIDs: []DupeEpisodeMatch{{ID: "1"}}}, + }, + {Tracker: "AITHER", Status: "completed"}, + }} + evidence := NewAcceptedDuplicateEvidence( + ReleaseRef{SourcePath: "Example.Release.2026.1080p-GRP.mkv", Generation: 1}, + []string{"blu"}, + summary, + ) + if len(evidence.Results) != 1 || evidence.Results[0].Tracker != "BLU" { + t.Fatalf("accepted results = %#v", evidence.Results) + } + evidence.Results[0].Raw[0].Files[0] = "mutated.mkv" + evidence.Results[0].Match.MatchedEpisodeIDs[0].ID = "mutated" + if summary.Results[0].Raw[0].Files[0] != "video.mkv" || + summary.Results[0].Match.MatchedEpisodeIDs[0].ID != "1" { + t.Fatalf("summary mutated through evidence: %#v", summary.Results[0]) + } +} diff --git a/pkg/api/dvd_menu_progress.go b/pkg/api/dvd_menu_progress.go index 8594ae1f..c5800a41 100644 --- a/pkg/api/dvd_menu_progress.go +++ b/pkg/api/dvd_menu_progress.go @@ -24,38 +24,6 @@ type DVDMenuProgressUpdate struct { WarningCount int `json:"warningCount"` } -// DVDMenuCaptureSnapshot reports frontend-visible state for one capture job. -type DVDMenuCaptureSnapshot struct { - // JobID is the opaque identifier used for polling and cancellation. - JobID string `json:"jobID"` - // SourcePath is the host filesystem path associated with the prepared metadata. - SourcePath string `json:"sourcePath"` - // Status is queued, running, completed, failed, or canceled. - Status string `json:"status"` - // Phase is the current capture stage within Status. - Phase string `json:"phase"` - // Message is a user-facing progress or terminal summary. - Message string `json:"message"` - // DiscoveredMenus is the latest structural inventory count. - DiscoveredMenus int `json:"discoveredMenus"` - // VisitedStates is the latest evaluated VM-state count. - VisitedStates int `json:"visitedStates"` - // VisitedButtons is the latest evaluated button-command count. - VisitedButtons int `json:"visitedButtons"` - // CapturedCount is the latest rendered or persisted image count. - CapturedCount int `json:"capturedCount"` - // WarningCount is the latest distinct coverage-warning count. - WarningCount int `json:"warningCount"` - // Result is populated with the final or partial engine result when available. - Result DVDMenuCaptureResult `json:"result"` - // Error contains terminal failure text and is empty for successful jobs. - Error string `json:"error"` - // StartedAt is an RFC3339 UTC timestamp. - StartedAt string `json:"startedAt"` - // FinishedAt is an RFC3339 UTC timestamp, or empty while the job is active. - FinishedAt string `json:"finishedAt"` -} - // DVDMenuProgressReporter receives capture progress updates. type DVDMenuProgressReporter func(update DVDMenuProgressUpdate) diff --git a/pkg/api/dvd_menus.go b/pkg/api/dvd_menus.go index 69c893ba..d3c11000 100644 --- a/pkg/api/dvd_menus.go +++ b/pkg/api/dvd_menus.go @@ -8,7 +8,7 @@ import "context" // DVDMenuDiscovery describes how the navigation engine found a menu screen. type DVDMenuDiscovery string -// DVD menu discovery sources serialized across CLI, Wails, and embedded web. +// DVD menu discovery sources serialized across CLI and WebUI. const ( // DVDMenuDiscoveryReachable marks a screen reached by deterministic VM navigation. DVDMenuDiscoveryReachable DVDMenuDiscovery = "reachable" diff --git a/pkg/api/history.go b/pkg/api/history.go index bb8c91e4..58f61660 100644 --- a/pkg/api/history.go +++ b/pkg/api/history.go @@ -17,10 +17,35 @@ type HistoryEntry struct { LatestUploadStatus string LatestUploadAt time.Time `ts_type:"string"` RuleFailureCount int - // RuleWarningCount excludes blocking, legacy, and unrecognized rule results. - RuleWarningCount int + // RuleAdvisoryCount contains non-blocking advisory rule results. + RuleAdvisoryCount int } +// HistoryRecord is the persistence-facing history aggregate. Prepared facts +// remain behind PreparedReleaseRepository and are referenced only by exact ID. +type HistoryRecord struct { + SourcePath string + ReleaseTitle string + ReleaseSource string + ReleaseResolution string + MetadataUpdatedAt time.Time `ts_type:"string"` + LatestUploadStatus string + LatestUploadAt time.Time `ts_type:"string"` + Metadata FileMetadata + PreparedReleaseRef *ReleaseRef + ReleaseNameOverrides ReleaseNameOverrides + DescriptionOverride DescriptionOverride + DescriptionOverrides []DescriptionOverride + PlaylistSelection PlaylistSelection + TrackerMetadata []TrackerMetadata + TrackerRuleFailures []TrackerRuleFailure + Screenshots []Screenshot + FinalSelections []ScreenshotFinalSelection + UploadedImages []UploadedImageLink + UploadHistory []UploadRecord +} + +// HistoryOverview is the transport projection for one stored release. type HistoryOverview struct { SourcePath string ReleaseTitle string @@ -31,8 +56,9 @@ type HistoryOverview struct { LatestUploadAt time.Time `ts_type:"string"` StatusLabel string Metadata FileMetadata - ExternalIDs ExternalIDs - ExternalMetadata ExternalMetadata + Release ReleaseRef + Identity ExternalIdentity + Display PreparedReleaseDisplay ReleaseNameOverrides ReleaseNameOverrides DescriptionOverride DescriptionOverride DescriptionOverrides []DescriptionOverride diff --git a/pkg/api/image_upload_progress.go b/pkg/api/image_upload_progress.go new file mode 100644 index 00000000..0e50ffdb --- /dev/null +++ b/pkg/api/image_upload_progress.go @@ -0,0 +1,121 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "context" + "strings" +) + +// ImageUploadProgressStatus describes the latest advisory state of one host attempt. +type ImageUploadProgressStatus string + +const ( + // ImageUploadProgressRunning marks a host attempt that still has pending images. + ImageUploadProgressRunning ImageUploadProgressStatus = "running" + // ImageUploadProgressCompleted marks a host attempt whose images are all ready. + ImageUploadProgressCompleted ImageUploadProgressStatus = "completed" + // ImageUploadProgressFailed marks a host attempt with one or more failed images. + ImageUploadProgressFailed ImageUploadProgressStatus = "failed" +) + +// ImageUploadProgressTarget identifies one host/scope attempt and its immutable +// presentation metadata. Total includes reused images as well as network uploads. +type ImageUploadProgressTarget struct { + // AttemptID is stable for the host/scope pair within one command. + AttemptID string + // Host is the normalized configured image-host name. + Host string + // UsageScope distinguishes global and tracker-owned upload records. + UsageScope string + // Trackers lists upload targets served by this host attempt. + Trackers []string + // Fallback reports whether this attempt follows an earlier host failure. + Fallback bool + // Total is the number of selected images required on this host. + Total int + // Reused is the subset already available from persisted host records. + Reused int +} + +// ImageUploadProgressUpdate is one frontend-safe absolute snapshot for a host +// attempt. CorrelationID and Timestamp are injected by the WebUI boundary. +type ImageUploadProgressUpdate struct { + // CorrelationID binds the update to one frontend upload command. + CorrelationID string `json:"correlationID"` + // AttemptID identifies the host/scope row updated by this snapshot. + AttemptID string `json:"attemptID"` + // Host is the normalized configured image-host name. + Host string `json:"host"` + // UsageScope distinguishes global and tracker-owned upload records. + UsageScope string `json:"usageScope"` + // Trackers lists upload targets served by this host attempt. + Trackers []string `json:"trackers"` + // Fallback reports whether this attempt follows an earlier host failure. + Fallback bool `json:"fallback"` + // Completed counts succeeded, failed, and reused images in terminal states. + Completed int `json:"completed"` + // Total is the number of selected images required on this host. + Total int `json:"total"` + // Succeeded counts completed network uploads in this attempt. + Succeeded int `json:"succeeded"` + // Failed counts completed network uploads that did not produce a usable link. + Failed int `json:"failed"` + // Reused counts images satisfied by existing persisted host records. + Reused int `json:"reused"` + // Status is the latest advisory state for this host attempt. + Status ImageUploadProgressStatus `json:"status"` + // Message provides frontend-safe detail about the latest transition. + Message string `json:"message"` + // Timestamp is an RFC3339 timestamp injected by the WebUI boundary. + Timestamp string `json:"timestamp"` +} + +// ImageUploadProgressReporter receives advisory image-host progress snapshots. +type ImageUploadProgressReporter func(update ImageUploadProgressUpdate) + +type imageUploadProgressReporterKey struct{} +type imageUploadProgressTargetKey struct{} + +// WithImageUploadProgressReporter attaches an optional reporter to ctx. +func WithImageUploadProgressReporter(ctx context.Context, reporter ImageUploadProgressReporter) context.Context { + if ctx == nil || reporter == nil { + return ctx + } + return context.WithValue(ctx, imageUploadProgressReporterKey{}, reporter) +} + +// WithImageUploadProgressTarget attaches immutable metadata for one host attempt. +func WithImageUploadProgressTarget(ctx context.Context, target ImageUploadProgressTarget) context.Context { + if ctx == nil { + return ctx + } + target.AttemptID = strings.TrimSpace(target.AttemptID) + target.Host = strings.ToLower(strings.TrimSpace(target.Host)) + target.UsageScope = strings.TrimSpace(target.UsageScope) + target.Trackers = append([]string(nil), target.Trackers...) + target.Total = max(0, target.Total) + target.Reused = max(0, min(target.Reused, target.Total)) + return context.WithValue(ctx, imageUploadProgressTargetKey{}, target) +} + +// ImageUploadProgressTargetFromContext returns the current host attempt when present. +func ImageUploadProgressTargetFromContext(ctx context.Context) (ImageUploadProgressTarget, bool) { + if ctx == nil { + return ImageUploadProgressTarget{}, false + } + target, ok := ctx.Value(imageUploadProgressTargetKey{}).(ImageUploadProgressTarget) + return target, ok +} + +// EmitImageUploadProgress reports one absolute snapshot when a reporter is installed. +func EmitImageUploadProgress(ctx context.Context, update ImageUploadProgressUpdate) { + if ctx == nil { + return + } + reporter, _ := ctx.Value(imageUploadProgressReporterKey{}).(ImageUploadProgressReporter) + if reporter != nil { + reporter(update) + } +} diff --git a/pkg/api/image_upload_progress_test.go b/pkg/api/image_upload_progress_test.go new file mode 100644 index 00000000..b7a4887f --- /dev/null +++ b/pkg/api/image_upload_progress_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestImageUploadProgressContextAndJSONContract(t *testing.T) { + var updates []ImageUploadProgressUpdate + ctx := WithImageUploadProgressReporter(context.Background(), func(update ImageUploadProgressUpdate) { + updates = append(updates, update) + }) + target := ImageUploadProgressTarget{ + AttemptID: "imgbox|global", + Host: "IMGBOX", + UsageScope: "global", + Trackers: []string{"AITHER", "ANT"}, + Total: 4, + Reused: 1, + } + ctx = WithImageUploadProgressTarget(ctx, target) + stored, ok := ImageUploadProgressTargetFromContext(ctx) + if !ok || stored.Host != "imgbox" || stored.Reused != 1 { + t.Fatalf("unexpected stored target: %#v", stored) + } + + EmitImageUploadProgress(ctx, ImageUploadProgressUpdate{ + AttemptID: "imgbox|global", + Host: "imgbox", + Completed: 2, + Total: 4, + Succeeded: 1, + Reused: 1, + Status: ImageUploadProgressRunning, + }) + if len(updates) != 1 || updates[0].Completed != 2 { + t.Fatalf("unexpected updates: %#v", updates) + } + encoded, err := json.Marshal(updates[0]) + if err != nil { + t.Fatalf("marshal update: %v", err) + } + jsonValue := string(encoded) + for _, field := range []string{`"attemptID"`, `"completed"`, `"succeeded"`, `"fallback"`} { + if !strings.Contains(jsonValue, field) { + t.Fatalf("JSON missing %s: %s", field, jsonValue) + } + } +} diff --git a/pkg/api/metadata_errors.go b/pkg/api/metadata_errors.go index f4649177..71e905d0 100644 --- a/pkg/api/metadata_errors.go +++ b/pkg/api/metadata_errors.go @@ -5,15 +5,20 @@ package api import "strings" -const ErrCodeBDMVRescanRequired = "bdmv_rescan_required" - +// BDMVRescanRequiredError reports a partial cached Blu-ray analysis that needs +// caller confirmation before rescanning the selected playlists. type BDMVRescanRequiredError struct { - SourcePath string + // SourcePath identifies the requested local Blu-ray source. + SourcePath string + // SelectedPlaylists contains the complete requested playlist set. SelectedPlaylists []string - CachedPlaylists []string - MissingPlaylists []string + // CachedPlaylists contains the playlists already present in the cache. + CachedPlaylists []string + // MissingPlaylists contains the selected playlists that require a rescan. + MissingPlaylists []string } +// Error returns a stable confirmation-required message without the source path. func (e *BDMVRescanRequiredError) Error() string { if e == nil { return "" @@ -23,3 +28,34 @@ func (e *BDMVRescanRequiredError) Error() string { } return "BDMV rescan confirmation required for playlist(s): " + strings.Join(e.MissingPlaylists, ", ") } + +// PlaylistSelectionRequiredError reports a BDMV preparation without a usable +// direct or remembered playlist selection. +type PlaylistSelectionRequiredError struct { + // SourcePath identifies the requested local Blu-ray source. + SourcePath string +} + +// Error returns the stable playlist-selection-required message. +func (*PlaylistSelectionRequiredError) Error() string { + return "Blu-ray playlist selection is required" +} + +// InvalidPlaylistSelectionError reports a selected playlist that cannot be +// safely resolved from the current BDMV source. +type InvalidPlaylistSelectionError struct { + // SourcePath identifies the requested local Blu-ray source. + SourcePath string + // Playlist identifies the rejected playlist when one was available. + Playlist string + // Reason explains the validation failure and is appended to Error's result. + Reason string +} + +// Error returns the stable invalid-selection message and any non-empty reason. +func (e *InvalidPlaylistSelectionError) Error() string { + if e == nil || strings.TrimSpace(e.Reason) == "" { + return "Blu-ray playlist selection is invalid" + } + return "Blu-ray playlist selection is invalid: " + strings.TrimSpace(e.Reason) +} diff --git a/pkg/api/metadata_progress.go b/pkg/api/metadata_progress.go deleted file mode 100644 index 91c71af3..00000000 --- a/pkg/api/metadata_progress.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2025-2026, Audionut and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package api - -import "context" - -type MetadataProgressUpdate struct { - Path string `json:"path"` - Phase string `json:"phase"` - Message string `json:"message"` - Status string `json:"status"` - Level string `json:"level"` - Timestamp string `json:"timestamp"` -} - -type MetadataProgressReporter func(update MetadataProgressUpdate) - -type metadataProgressReporterKey struct{} - -func WithMetadataProgressReporter(ctx context.Context, reporter MetadataProgressReporter) context.Context { - if ctx == nil { - ctx = context.Background() - } - if reporter == nil { - return ctx - } - return context.WithValue(ctx, metadataProgressReporterKey{}, reporter) -} - -func EmitMetadataProgress(ctx context.Context, update MetadataProgressUpdate) { - if ctx == nil { - return - } - reporter, _ := ctx.Value(metadataProgressReporterKey{}).(MetadataProgressReporter) - if reporter == nil { - return - } - reporter(update) -} diff --git a/pkg/api/operation_failure.go b/pkg/api/operation_failure.go new file mode 100644 index 00000000..19678824 --- /dev/null +++ b/pkg/api/operation_failure.go @@ -0,0 +1,109 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import "errors" + +// OperationKind identifies the domain operation that failed. +type OperationKind string + +const ( + OperationKindUnknown OperationKind = "unknown" + OperationKindPreparation OperationKind = "preparation" + OperationKindDuplicateCheck OperationKind = "duplicate_check" + OperationKindDryRun OperationKind = "dry_run" + OperationKindUploadReview OperationKind = "upload_review" + OperationKindUploadExecute OperationKind = "upload_execute" + OperationKindMedia OperationKind = "media" + OperationKindDescription OperationKind = "description" + OperationKindImageHosting OperationKind = "image_hosting" +) + +// OperationFailureCode is a stable machine-readable failure classification. +type OperationFailureCode string + +const ( + OperationFailureInvalidInput OperationFailureCode = "invalid_input" + OperationFailureInvalidSource OperationFailureCode = "invalid_source" + OperationFailureConfirmationRequired OperationFailureCode = "confirmation_required" + OperationFailureStaleGeneration OperationFailureCode = "stale_generation" + OperationFailureIncompatibleGeneration OperationFailureCode = "incompatible_generation" + OperationFailureMissingPrerequisite OperationFailureCode = "missing_prerequisite" + OperationFailureTrackerAuthRequired OperationFailureCode = "tracker_auth_required" + OperationFailureNoEligibleTrackers OperationFailureCode = "no_eligible_trackers" + OperationFailureStaleReview OperationFailureCode = "stale_review" + OperationFailureMissingReview OperationFailureCode = "missing_review" + OperationFailureInternal OperationFailureCode = "internal" +) + +// OperationRecovery is the required caller action for a stable failure. +type OperationRecovery string + +const ( + OperationRecoveryNone OperationRecovery = "none" + OperationRecoveryEditInput OperationRecovery = "edit_input" + OperationRecoveryConfirm OperationRecovery = "confirm" + OperationRecoveryRefreshRelease OperationRecovery = "refresh_release" + OperationRecoveryCompletePrerequisite OperationRecovery = "complete_prerequisite" + OperationRecoveryAuthenticateTrackers OperationRecovery = "authenticate_trackers" + OperationRecoverySelectTrackers OperationRecovery = "select_trackers" + OperationRecoveryReviewAgain OperationRecovery = "review_again" + OperationRecoveryRetry OperationRecovery = "retry" +) + +// OperationFailure is safe for CLI and browser transport. Raw causes are +// intentionally excluded. +type OperationFailure struct { + Code OperationFailureCode + Operation OperationKind + Message string + Recovery OperationRecovery +} + +// OperationError preserves one wrapped cause while exposing only its safe +// failure through Error and Failure. +type OperationError struct { + failure OperationFailure + cause error +} + +// NewOperationError creates a typed safe operation error. +func NewOperationError(failure OperationFailure, cause error) *OperationError { + if cause == nil { + cause = errors.New("operation failed") + } + return &OperationError{failure: failure, cause: cause} +} + +func (e *OperationError) Error() string { + if e == nil { + return "operation failed" + } + return e.failure.Message +} + +// Unwrap retains errors.Is/errors.As access to the private cause. +func (e *OperationError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +// Failure returns the stable safe transport value. +func (e *OperationError) Failure() OperationFailure { + if e == nil { + return OperationFailure{} + } + return e.failure +} + +// AsOperationFailure extracts a stable failure from a wrapped error chain. +func AsOperationFailure(err error) (OperationFailure, bool) { + var operationErr *OperationError + if !errors.As(err, &operationErr) { + return OperationFailure{}, false + } + return operationErr.Failure(), true +} diff --git a/pkg/api/operation_failure_test.go b/pkg/api/operation_failure_test.go new file mode 100644 index 00000000..4a60831d --- /dev/null +++ b/pkg/api/operation_failure_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "errors" + "fmt" + "testing" +) + +func TestOperationErrorExposesSafeFailureAndRetainsCause(t *testing.T) { + t.Parallel() + + cause := errors.New("token=secret-value") + failure := OperationFailure{ + Code: OperationFailureNoEligibleTrackers, + Operation: OperationKindUploadReview, + Message: "No selected trackers are eligible.", + Recovery: OperationRecoverySelectTrackers, + } + err := fmt.Errorf("core boundary: %w", NewOperationError(failure, cause)) + if err.Error() != "core boundary: No selected trackers are eligible." { + t.Fatalf("safe error = %q", err) + } + if !errors.Is(err, cause) { + t.Fatal("wrapped cause was not retained") + } + actual, ok := AsOperationFailure(err) + if !ok || actual != failure { + t.Fatalf("failure = %#v, %v", actual, ok) + } +} diff --git a/pkg/api/operations.go b/pkg/api/operations.go new file mode 100644 index 00000000..dc06f190 --- /dev/null +++ b/pkg/api/operations.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +// DuplicateCheckInput contains duplicate-check choices for one exact prepared +// generation. It does not carry prepared facts. +type DuplicateCheckInput struct { + Release ReleaseRef + Trackers []string + Interaction InteractionMode + IgnoreFor []string + Skip bool + SkipAsActual bool + DoubleCheck bool + Authorizations []string + // TrackerIDs contains explicit tracker IDs that override discovered client evidence. + TrackerIDs map[string]string +} + +// UploadReviewInput contains review-time workflow choices for one exact +// prepared generation. +type UploadReviewInput struct { + Release ReleaseRef + Trackers []string + IgnoreDupesFor []string + RuleAuthorizations []RuleAuthorization + SkipDuplicateCheck bool + SkipDuplicateAsActual bool + DoubleDuplicateCheck bool + QuestionnaireAnswers map[string]map[string]string + TrackerIDOverrides map[string]string + DescriptionGroups []DescriptionBuilderGroup + TrackerConfigOverrides TrackerConfigOverrides + TrackerSiteOverrides TrackerSiteOverrides + ClientOverrides ClientOverrides + ImageHostOverrides ImageHostOverrides + ScreenshotOverrides ScreenshotOverrides + TorrentOverrides TorrentOverrides + Options UploadOptions +} + +// UploadReviewOutcome contains tracker-scoped decisions established by one +// completed review. It excludes prepared facts and mutable runtime state. +type UploadReviewOutcome struct { + ResolvedTrackers []string + Eligibility TrackerEligibility + MatchedTrackers []string + BlockedTrackers map[string][]TrackerBlockReason + TrackerRuleFailures map[string][]RuleFailure + CrossSeedTorrents []UploadedTorrent +} + +// ReviewedUpload is the authoritative review result retained for accepted +// execution. Review is transport-visible; Outcome is execution-only state. +type ReviewedUpload struct { + Review UploadReview + Outcome UploadReviewOutcome +} + +// UploadExecutionPlan combines operation choices with the exact outcomes +// authorized by review. Prepared facts remain behind Release and its seed. +type UploadExecutionPlan struct { + Input UploadReviewInput + Outcome UploadReviewOutcome +} + +// UploadExecutionInput consumes one opaque, owner-scoped reviewed execution +// snapshot. No live preparation or runtime state is accepted. +type UploadExecutionInput struct { + ReviewToken string +} + +// TrackerDryRunInput contains tracker-preview choices for one exact prepared +// generation. +type TrackerDryRunInput struct { + Release ReleaseRef + Trackers []string + IgnoreDupesFor []string + QuestionnaireAnswers map[string]map[string]string + DescriptionGroups []DescriptionBuilderGroup + TrackerIDOverrides map[string]string + TrackerConfigOverrides TrackerConfigOverrides + TrackerSiteOverrides TrackerSiteOverrides + ClientOverrides ClientOverrides + ImageHostOverrides ImageHostOverrides + ScreenshotOverrides ScreenshotOverrides + TorrentOverrides TorrentOverrides + Options UploadOptions +} + +// AcceptedDuplicateEvidence contains one completed duplicate-check outcome for +// the exact release and tracker selection consumed by a tracker dry run. +type AcceptedDuplicateEvidence struct { + Release ReleaseRef + Trackers []string + Results []DupeCheckResult +} + +// TrackerDryRunPlan combines dry-run choices with the completed duplicate +// evidence accepted by the caller. Core validates both parts before building +// tracker payloads or injecting any torrent. +type TrackerDryRunPlan struct { + Input TrackerDryRunInput + Duplicate AcceptedDuplicateEvidence +} + +// MediaPlanInput contains media planning choices for one exact prepared +// generation. +type MediaPlanInput struct { + Release ReleaseRef + Count int + Purpose ScreenshotPurpose + Options ScreenshotOverrides +} + +// DescriptionInput contains description projection choices for one exact +// prepared generation. +type DescriptionInput struct { + Release ReleaseRef + Trackers []string + GroupKey string + Groups []DescriptionBuilderGroup + ImageHost ImageHostOverrides + QuestionnaireData map[string]map[string]string + Options UploadOptions +} + +// ImageHostingInput contains image-host choices for one exact prepared +// generation. Selected images are supplied separately to keep the subject +// contract independent from transport payloads. +type ImageHostingInput struct { + Release ReleaseRef + Trackers []string + Host string + Scope string +} + +// UploadReviewResult returns the review plus the opaque token required for +// execution. The token identifies one exact preparation/runtime snapshot. +type UploadReviewResult struct { + Review UploadReview + Token string +} diff --git a/pkg/api/preparation_progress.go b/pkg/api/preparation_progress.go new file mode 100644 index 00000000..432b4df0 --- /dev/null +++ b/pkg/api/preparation_progress.go @@ -0,0 +1,185 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "context" + "strings" +) + +// PreparationProgressPhase identifies one stable stage in canonical release +// preparation. It is presentation telemetry, not prepared-release evidence. +type PreparationProgressPhase string + +const ( + // PreparationPhaseSourceInspection validates and classifies the requested source. + PreparationPhaseSourceInspection PreparationProgressPhase = "source_inspection" + // PreparationPhasePreparedCache checks whether an existing generation can be reused. + PreparationPhasePreparedCache PreparationProgressPhase = "prepared_cache" + // PreparationPhaseSourceEvidence collects filesystem and media-source facts. + PreparationPhaseSourceEvidence PreparationProgressPhase = "source_evidence" + // PreparationPhaseBDInfo analyzes selected Blu-ray playlists. + PreparationPhaseBDInfo PreparationProgressPhase = "bdinfo" + // PreparationPhaseClientDiscovery searches configured torrent clients. + PreparationPhaseClientDiscovery PreparationProgressPhase = "client_discovery" + // PreparationPhaseTrackerEvidence collects source metadata from trackers. + PreparationPhaseTrackerEvidence PreparationProgressPhase = "tracker_evidence" + // PreparationPhaseMediaInfoIdentity reads identifiers exposed by MediaInfo. + PreparationPhaseMediaInfoIdentity PreparationProgressPhase = "mediainfo_identity" + // PreparationPhaseArrIdentity queries configured Sonarr and Radarr services. + PreparationPhaseArrIdentity PreparationProgressPhase = "arr_identity" + // PreparationPhaseExternalIdentity resolves the canonical provider identity. + PreparationPhaseExternalIdentity PreparationProgressPhase = "external_identity" + // PreparationPhaseMediaFacts derives normalized release media facts. + PreparationPhaseMediaFacts PreparationProgressPhase = "media_facts" + // PreparationPhaseCanonicalIdentity finalizes canonical release identity. + PreparationPhaseCanonicalIdentity PreparationProgressPhase = "canonical_identity" + // PreparationPhaseGenerationCommit persists the prepared generation. + PreparationPhaseGenerationCommit PreparationProgressPhase = "generation_commit" + // PreparationPhasePreviewProjection builds the transport metadata preview. + PreparationPhasePreviewProjection PreparationProgressPhase = "preview_projection" + // PreparationPhaseResetCleanup removes reusable metadata before a forced reset. + PreparationPhaseResetCleanup PreparationProgressPhase = "reset_cleanup" + // PreparationPhaseCandidateSelection applies a selected Blu-ray candidate. + PreparationPhaseCandidateSelection PreparationProgressPhase = "candidate_selection" +) + +// PreparationProgressStatus describes the latest advisory state of one stage. +type PreparationProgressStatus string + +const ( + // PreparationProgressRunning marks a stage that has started but is not terminal. + PreparationProgressRunning PreparationProgressStatus = "running" + // PreparationProgressCompleted marks a successfully completed stage. + PreparationProgressCompleted PreparationProgressStatus = "completed" + // PreparationProgressSkipped marks an intentional cache or policy bypass. + PreparationProgressSkipped PreparationProgressStatus = "skipped" + // PreparationProgressFailed marks a stage whose owning operation failed. + PreparationProgressFailed PreparationProgressStatus = "failed" +) + +// PreparationProgressUpdate is one frontend-safe advisory preparation event. +// CorrelationID and Timestamp are injected by the WebUI transport boundary. +type PreparationProgressUpdate struct { + // CorrelationID binds the update to one frontend preparation command. + CorrelationID string `json:"correlationID"` + // Phase identifies the stable preparation stage. + Phase PreparationProgressPhase `json:"phase"` + // Order is the canonical presentation order, not a completion percentage. + Order int `json:"order"` + // Label is the user-facing stage name. + Label string `json:"label"` + // Message describes the current stage transition. + Message string `json:"message"` + // Status is the latest advisory state for Phase. + Status PreparationProgressStatus `json:"status"` + // Timestamp is an RFC3339 timestamp injected by the WebUI boundary. + Timestamp string `json:"timestamp"` +} + +// PreparationProgressReporter receives advisory preparation progress. +type PreparationProgressReporter func(update PreparationProgressUpdate) + +// preparationProgressReporterKey prevents collisions with caller context values. +type preparationProgressReporterKey struct{} + +// WithPreparationProgressReporter attaches an optional reporter to ctx. +func WithPreparationProgressReporter(ctx context.Context, reporter PreparationProgressReporter) context.Context { + if ctx == nil || reporter == nil { + return ctx + } + return context.WithValue(ctx, preparationProgressReporterKey{}, reporter) +} + +// EmitPreparationProgress reports one update when a reporter is installed. +func EmitPreparationProgress(ctx context.Context, update PreparationProgressUpdate) { + if ctx == nil { + return + } + reporter, _ := ctx.Value(preparationProgressReporterKey{}).(PreparationProgressReporter) + if reporter != nil { + reporter(update) + } +} + +// NewPreparationProgressUpdate supplies the canonical order and label for a +// phase. Unknown phases remain renderable with a normalized fallback label. +func NewPreparationProgressUpdate( + phase PreparationProgressPhase, + status PreparationProgressStatus, + message string, +) PreparationProgressUpdate { + order, label := preparationProgressPresentation(phase) + return PreparationProgressUpdate{ + Phase: phase, + Order: order, + Label: label, + Message: strings.TrimSpace(message), + Status: status, + } +} + +// BeginPreparationProgress emits a running event and returns a completion +// callback. The callback never replaces or modifies the operation error. +func BeginPreparationProgress( + ctx context.Context, + phase PreparationProgressPhase, + message string, +) func(error) { + EmitPreparationProgress(ctx, NewPreparationProgressUpdate(phase, PreparationProgressRunning, message)) + return func(err error) { + if err != nil { + EmitPreparationProgress(ctx, NewPreparationProgressUpdate(phase, PreparationProgressFailed, "Stage failed.")) + return + } + EmitPreparationProgress(ctx, NewPreparationProgressUpdate(phase, PreparationProgressCompleted, "Stage complete.")) + } +} + +// SkipPreparationProgress records an intentional cache or policy bypass. +func SkipPreparationProgress(ctx context.Context, phase PreparationProgressPhase, message string) { + EmitPreparationProgress(ctx, NewPreparationProgressUpdate(phase, PreparationProgressSkipped, message)) +} + +// preparationProgressPresentation returns stable sorting and display metadata +// while keeping unknown phases visible after mixed-version deployments. +func preparationProgressPresentation(phase PreparationProgressPhase) (int, string) { + switch phase { + case PreparationPhaseSourceInspection: + return 100, "Inspect source" + case PreparationPhasePreparedCache: + return 200, "Check prepared cache" + case PreparationPhaseResetCleanup: + return 250, "Reset previous metadata" + case PreparationPhaseCandidateSelection: + return 275, "Select Blu-ray candidate" + case PreparationPhaseSourceEvidence: + return 300, "Collect source evidence" + case PreparationPhaseBDInfo: + return 350, "Analyze Blu-ray playlists" + case PreparationPhaseClientDiscovery: + return 400, "Search torrent clients" + case PreparationPhaseTrackerEvidence: + return 500, "Collect tracker evidence" + case PreparationPhaseMediaInfoIdentity: + return 600, "Read MediaInfo identity" + case PreparationPhaseArrIdentity: + return 700, "Query Sonarr and Radarr" + case PreparationPhaseExternalIdentity: + return 800, "Resolve external identity" + case PreparationPhaseMediaFacts: + return 900, "Derive media facts" + case PreparationPhaseCanonicalIdentity: + return 1000, "Resolve canonical identity" + case PreparationPhaseGenerationCommit: + return 1100, "Commit prepared generation" + case PreparationPhasePreviewProjection: + return 1200, "Build metadata preview" + } + label := strings.TrimSpace(strings.ReplaceAll(string(phase), "_", " ")) + if label == "" { + label = "Preparation" + } + return 10000, label +} diff --git a/pkg/api/preparation_progress_test.go b/pkg/api/preparation_progress_test.go new file mode 100644 index 00000000..38df7f41 --- /dev/null +++ b/pkg/api/preparation_progress_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "context" + "encoding/json" + "testing" +) + +func TestPreparationProgressUpdateJSONContract(t *testing.T) { + update := NewPreparationProgressUpdate(PreparationPhaseBDInfo, PreparationProgressRunning, "Scanning playlist.") + update.CorrelationID = "attempt-1" + update.Timestamp = "2026-07-16T00:00:00Z" + payload, err := json.Marshal(update) + if err != nil { + t.Fatalf("marshal preparation progress: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode preparation progress: %v", err) + } + if decoded["correlationID"] != "attempt-1" || decoded["phase"] != "bdinfo" || decoded["status"] != "running" { + t.Fatal("preparation progress transport fields changed") + } + if decoded["order"] != float64(350) || decoded["label"] != "Analyze Blu-ray playlists" { + t.Fatal("preparation progress presentation metadata changed") + } +} + +func TestPreparationProgressReporterAndUnknownPhase(t *testing.T) { + var updates []PreparationProgressUpdate + ctx := WithPreparationProgressReporter(context.Background(), func(update PreparationProgressUpdate) { + updates = append(updates, update) + }) + finish := BeginPreparationProgress(ctx, PreparationProgressPhase("future_phase"), "Starting.") + finish(nil) + if len(updates) != 2 { + t.Fatalf("updates=%d, want 2", len(updates)) + } + if updates[0].Order != 10000 || updates[0].Label != "future phase" { + t.Fatalf("unknown phase presentation=%+v", updates[0]) + } + if updates[1].Status != PreparationProgressCompleted { + t.Fatalf("terminal status=%q", updates[1].Status) + } +} diff --git a/pkg/api/prepare.go b/pkg/api/prepare.go new file mode 100644 index 00000000..22db0bf4 --- /dev/null +++ b/pkg/api/prepare.go @@ -0,0 +1,87 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +// PreparationIntent describes why preparation was requested. It may influence +// reuse/progress decisions but never the canonical fact set. +type PreparationIntent string + +const ( + // PreparationIntentPreview prepares a release for metadata preview. + PreparationIntentPreview PreparationIntent = "preview" + // PreparationIntentDuplicateCheck prepares a release for duplicate checking. + PreparationIntentDuplicateCheck PreparationIntent = "duplicate_check" + // PreparationIntentMedia prepares a release for media work. + PreparationIntentMedia PreparationIntent = "media" + // PreparationIntentDescription prepares a release for description work. + PreparationIntentDescription PreparationIntent = "description" + // PreparationIntentDryRun prepares a release for tracker dry run. + PreparationIntentDryRun PreparationIntent = "dry_run" + // PreparationIntentUpload prepares a release for upload review or execution. + PreparationIntentUpload PreparationIntent = "upload" +) + +// PrepareInput requests one canonical prepared-release generation. +type PrepareInput struct { + SourcePath string + Intent PreparationIntent + Instructions ReleaseFactInstructions + Policy PreparationPolicy + // Search contains fact-producing client search choices included in compatibility. + Search ClientSearchPolicy + // Controls contains one-shot permissions excluded from compatibility. + Controls PreparationControls + Force bool +} + +// ReleaseFactInstructions contains caller intent that can change finalized +// reusable release facts. +type ReleaseFactInstructions struct { + Identity ExternalIDOverrides + Category *CanonicalCategory + ReleaseName ReleaseNameOverrides + Metadata MetadataOverrides + SourceLookup string + BlurayReleaseID string + Playlist PlaylistInstruction + // TrackerIDs maps normalized tracker names to caller-supplied source IDs. + TrackerIDs map[string]string +} + +// PlaylistInstruction preserves tri-state playlist selection: Set=false means +// no instruction, while Set=true with an empty selection is an explicit clear. +type PlaylistInstruction struct { + Set bool + Selected []string + UseAll bool +} + +// PreparationPolicy contains non-secret preparation behavior that can affect +// canonical fact derivation and therefore participates in compatibility. +type PreparationPolicy struct { + KeepFolder bool + // KeepImages preserves tracker-sourced images while collecting metadata evidence. + KeepImages bool + OnlyID bool +} + +// ClientSearchPolicy contains fact-producing torrent-client search choices. +// These values participate in preparation compatibility. +type ClientSearchPolicy struct { + // Skip disables torrent-client discovery for this preparation. + Skip bool + // Client optionally selects one configured client; nil uses normal client selection. + Client *string +} + +// PreparationControls contains one-shot permissions and presentation choices. +// These values never participate in preparation compatibility. +type PreparationControls struct { + // Interaction determines whether preparation may request manual input. + Interaction InteractionMode + // ConfirmBDMVRescan permits replacing a partial cached Blu-ray analysis. + ConfirmBDMVRescan bool + // ForceRecheck forwards an explicit torrent-client hash recheck choice. + ForceRecheck *bool +} diff --git a/pkg/api/prepared_release.go b/pkg/api/prepared_release.go new file mode 100644 index 00000000..40a370b2 --- /dev/null +++ b/pkg/api/prepared_release.go @@ -0,0 +1,550 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// PreparedGeneration identifies one immutable prepared-release generation for +// a normalized source path. +type PreparedGeneration uint64 + +// PreparationCompatibility records the four exact keys that permit reuse of a +// prepared-release generation. +type PreparationCompatibility struct { + SourceFingerprint string + FactInstructionFingerprint string + PolicyFingerprint string + ContractVersion string +} + +// PreparedRelease is the immutable, source-scoped read model of finalized +// release facts. Workflow choices, diagnostics, resources, and outcomes are +// intentionally excluded. +type PreparedRelease struct { + Generation PreparedGeneration + Compatibility PreparationCompatibility + Source SourceManifest + Naming NamingFacts + Episode EpisodeFacts + Media MediaFacts + Disc DiscFacts + Identity ExternalIdentity + ProviderMetadata SourceScopedMetadata + Assessments ReleaseAssessments + PreparedAt time.Time `ts_type:"string"` +} + +// PrepareResult returns one immutable release projection plus non-canonical +// preparation diagnostics. +type PrepareResult struct { + Release PreparedRelease + Diagnostics []PreparationDiagnostic +} + +// ReleaseRef identifies one exact prepared generation without exposing its +// facts or private preparation resources. +type ReleaseRef struct { + SourcePath string + Generation PreparedGeneration +} + +// SourceEntryType classifies one normalized source-manifest entry. +type SourceEntryType string + +const ( + // SourceEntryTypeUnknown marks an entry whose type could not be classified. + SourceEntryTypeUnknown SourceEntryType = "unknown" + // SourceEntryTypeFile marks a regular file. + SourceEntryTypeFile SourceEntryType = "file" + // SourceEntryTypeDirectory marks a directory. + SourceEntryTypeDirectory SourceEntryType = "directory" + // SourceEntryTypePlaylist marks a resolved disc playlist. + SourceEntryTypePlaylist SourceEntryType = "playlist" +) + +// SourceManifest is the finalized source inventory and measurement set for one +// prepared release. +type SourceManifest struct { + SourcePath string + Size int64 + Entries []SourceManifestEntry + SelectedPlaylists []PlaylistInfo + Classification SourceClassification +} + +// SourceManifestEntry is one stable inventory item used by preparation and +// source fingerprinting. +type SourceManifestEntry struct { + Path string + Type SourceEntryType + Size int64 + ModifiedAt time.Time `ts_type:"string"` + Disc string + Playlist string +} + +// SourceClassification records finalized source structure without exposing +// preparation-resource paths. +type SourceClassification struct { + DiscType string + Container string + MediaType string +} + +// NamingFacts contains finalized reusable naming facts rather than raw parser +// output or workflow-specific name overrides. +type NamingFacts struct { + Filename string + ReleaseName string + NameWithoutTag string + CleanName string + Tag string + Type string + Artist string + Title string + Subtitle string + AlternateTitle string + Year int + Month int + Day int + Source string + Resolution string + Codecs []string + Audio []string + HDR []string + Extension string + Languages []string + Site string + Genre string + Channels string + Collection string + Region string + Size string + Group string + Disc string + Editions []string + Other []string + Scene bool + SceneName string + Personal bool +} + +// EpisodeFacts contains canonical reusable episodic identity and schedule +// facts, independent of tracker policy. +type EpisodeFacts struct { + Season int + Episode int + SeasonLabel string + EpisodeLabel string + DailyDate string + Pack bool + Title string + Overview string + Year int + AiredDate string + AirDays []string + AirTime string + AirTimezone string + AirTimezoneSource string + DateMatched bool +} + +// MediaFacts contains finalized reusable media characteristics. +type MediaFacts struct { + AudioLanguages []string + SubtitleLanguages []string + Container string + Audio string + Channels string + Commentary bool + ThreeD string + Source string + Type string + UHD string + HDR string + Distributor string + Region string + VideoCodec string + VideoEncode string + HasEncodeSettings bool + BitDepth string + Edition string + Repack string + WebDV bool + StreamOptimized int + Service string + ServiceLongName string + MediaInfoUniqueID string + Anime bool +} + +// DiscFacts contains typed disc measurements that are safe to publish as +// reusable facts. Local report and media paths remain preparation resources. +type DiscFacts struct { + Type string + Summary string + DurationSeconds float64 + PlaylistCount int + DVDVOBSet string +} + +// UniqueIDStatus records the concrete MediaInfo unique-ID assessment. +type UniqueIDStatus string + +const ( + // UniqueIDStatusUnknown means preparation has not established the fact. + UniqueIDStatusUnknown UniqueIDStatus = "unknown" + // UniqueIDStatusNotApplicable means the source does not require this evidence. + UniqueIDStatusNotApplicable UniqueIDStatus = "not_applicable" + // UniqueIDStatusPresent means required unique-ID evidence is present. + UniqueIDStatusPresent UniqueIDStatus = "present" + // UniqueIDStatusMissing means required unique-ID evidence is absent. + UniqueIDStatusMissing UniqueIDStatus = "missing" +) + +// EncodeSettingsStatus records the concrete MediaInfo encode-settings +// assessment. +type EncodeSettingsStatus string + +const ( + // EncodeSettingsStatusUnknown means preparation has not established the fact. + EncodeSettingsStatusUnknown EncodeSettingsStatus = "unknown" + // EncodeSettingsStatusNotApplicable means the source does not require encode settings. + EncodeSettingsStatusNotApplicable EncodeSettingsStatus = "not_applicable" + // EncodeSettingsStatusPresent means required encode settings are present. + EncodeSettingsStatusPresent EncodeSettingsStatus = "present" + // EncodeSettingsStatusMissing means required encode settings are absent. + EncodeSettingsStatusMissing EncodeSettingsStatus = "missing" +) + +// NamingStatus records whether finalized naming facts are complete. +type NamingStatus string + +const ( + // NamingStatusUnknown means preparation has not assessed naming completeness. + NamingStatusUnknown NamingStatus = "unknown" + // NamingStatusComplete means all required naming facts are present. + NamingStatusComplete NamingStatus = "complete" + // NamingStatusIncomplete means one or more required naming facts are missing. + NamingStatusIncomplete NamingStatus = "incomplete" +) + +// NamingAssessment records concrete naming completeness and typed missing fact +// labels without turning tracker policy into prepared truth. +type NamingAssessment struct { + Status NamingStatus + Missing []NamingRequirement +} + +// NamingRequirement identifies one missing finalized naming fact. +type NamingRequirement string + +// ReleaseAssessments contains concrete source-derived assessments. Operations +// decide whether these facts block work. +type ReleaseAssessments struct { + MediaInfoUniqueID UniqueIDStatus + MediaInfoEncodeSettings EncodeSettingsStatus + Naming NamingAssessment +} + +// UniqueIDRequirementSatisfied reports whether the concrete assessment permits +// work that requires a MediaInfo unique ID. +func (a ReleaseAssessments) UniqueIDRequirementSatisfied() bool { + return a.MediaInfoUniqueID == UniqueIDStatusPresent || a.MediaInfoUniqueID == UniqueIDStatusNotApplicable +} + +// EncodeSettingsRequirementSatisfied reports whether the concrete assessment +// permits work that requires MediaInfo encode settings. +func (a ReleaseAssessments) EncodeSettingsRequirementSatisfied() bool { + return a.MediaInfoEncodeSettings == EncodeSettingsStatusPresent || + a.MediaInfoEncodeSettings == EncodeSettingsStatusNotApplicable +} + +// CanonicalCategory is the finalized top-level movie-or-TV classification. +type CanonicalCategory string + +const ( + // CanonicalCategoryUnknown represents an intentionally or currently unknown category. + CanonicalCategoryUnknown CanonicalCategory = "unknown" + // CanonicalCategoryMovie identifies movie releases. + CanonicalCategoryMovie CanonicalCategory = "movie" + // CanonicalCategoryTV identifies television releases. + CanonicalCategoryTV CanonicalCategory = "tv" +) + +// IdentityProvider identifies one explicit external-ID provider. +type IdentityProvider string + +const ( + // IdentityProviderTMDB identifies The Movie Database. + IdentityProviderTMDB IdentityProvider = "tmdb" + // IdentityProviderIMDB identifies IMDb. + IdentityProviderIMDB IdentityProvider = "imdb" + // IdentityProviderTVDB identifies TheTVDB. + IdentityProviderTVDB IdentityProvider = "tvdb" + // IdentityProviderTVmaze identifies TVmaze. + IdentityProviderTVmaze IdentityProvider = "tvmaze" + // IdentityProviderMAL identifies MyAnimeList/AniList-compatible identity. + IdentityProviderMAL IdentityProvider = "mal" +) + +// IdentityProvenance identifies the origin of one canonical identity fact. +type IdentityProvenance string + +const ( + // IdentityProvenanceUnknown means no canonical fact has been resolved. + IdentityProvenanceUnknown IdentityProvenance = "unknown" + // IdentityProvenanceExplicit identifies a caller-locked fact. + IdentityProvenanceExplicit IdentityProvenance = "explicit" + // IdentityProvenancePersisted identifies reusable source-scoped state. + IdentityProvenancePersisted IdentityProvenance = "persisted" + // IdentityProvenanceMediaInfo identifies embedded MediaInfo evidence. + IdentityProvenanceMediaInfo IdentityProvenance = "mediainfo" + // IdentityProvenanceScene identifies scene/NFO evidence. + IdentityProvenanceScene IdentityProvenance = "scene" + // IdentityProvenanceArr identifies Sonarr or Radarr evidence. + IdentityProvenanceArr IdentityProvenance = "arr" + // IdentityProvenanceTracker identifies tracker evidence. + IdentityProvenanceTracker IdentityProvenance = "tracker" + // IdentityProvenanceProvider identifies provider-derived evidence. + IdentityProvenanceProvider IdentityProvenance = "provider" + // IdentityProvenanceLegacy identifies migrated pre-contract evidence. + IdentityProvenanceLegacy IdentityProvenance = "legacy" +) + +// IdentityProvenanceSet records provenance for every canonical provider and +// category fact. +type IdentityProvenanceSet struct { + TMDB IdentityProvenance + IMDB IdentityProvenance + TVDB IdentityProvenance + TVmaze IdentityProvenance + MAL IdentityProvenance + Category IdentityProvenance +} + +// OverrideState records tri-state caller intent. +type OverrideState string + +const ( + // OverrideStateUnset means automatic resolution may supply a fact. + OverrideStateUnset OverrideState = "unset" + // OverrideStateValue means a caller locked an explicit non-empty value. + OverrideStateValue OverrideState = "value" + // OverrideStateClear means a caller locked the fact as unknown. + OverrideStateClear OverrideState = "clear" +) + +// IdentityOverrideState records tri-state override intent for every canonical +// provider and the category. +type IdentityOverrideState struct { + TMDB OverrideState + IMDB OverrideState + TVDB OverrideState + TVmaze OverrideState + MAL OverrideState + Category OverrideState +} + +// IdentityConflictStatus records whether accepted evidence contains an +// unverified inconsistency. Proven locked conflicts are fatal and unpublished. +type IdentityConflictStatus string + +const ( + // IdentityConflictNone means no inconsistency was found. + IdentityConflictNone IdentityConflictStatus = "none" + // IdentityConflictUnverified means evidence may conflict but cannot be proven inconsistent. + IdentityConflictUnverified IdentityConflictStatus = "unverified" +) + +// IdentityResolutionKey records non-secret fingerprints controlling canonical +// identity reuse. +type IdentityResolutionKey struct { + SourceFingerprint string + IntentFingerprint string + ContractVersion string +} + +// ExternalIdentity is the only prepared-release source for provider IDs and +// top-level movie-or-TV classification. +type ExternalIdentity struct { + SourcePath string + Generation PreparedGeneration + TMDBID int + IMDBID int + TVDBID int + TVmazeID int + MALID int + Category CanonicalCategory + Provenance IdentityProvenanceSet + Overrides IdentityOverrideState + Conflict IdentityConflictStatus + Resolution IdentityResolutionKey + ResolvedAt time.Time `ts_type:"string"` +} + +// ProviderID returns the canonical ID for provider without applying fallbacks. +func (i ExternalIdentity) ProviderID(provider IdentityProvider) (int, bool) { + var id int + switch provider { + case IdentityProviderTMDB: + id = i.TMDBID + case IdentityProviderIMDB: + id = i.IMDBID + case IdentityProviderTVDB: + id = i.TVDBID + case IdentityProviderTVmaze: + id = i.TVmazeID + case IdentityProviderMAL: + id = i.MALID + default: + return 0, false + } + return id, id > 0 +} + +// RequirementKind identifies a canonical prerequisite required by an operation. +type RequirementKind string + +const ( + // RequirementKindProviderID identifies a missing provider ID. + RequirementKindProviderID RequirementKind = "provider_id" + // RequirementKindCategory identifies a missing canonical category. + RequirementKindCategory RequirementKind = "category" +) + +// MissingRequirementError reports one absent canonical prerequisite. +type MissingRequirementError struct { + Requirement RequirementKind + Provider IdentityProvider +} + +func (e *MissingRequirementError) Error() string { + if e == nil { + return "missing canonical requirement" + } + if e.Requirement == RequirementKindProviderID { + return fmt.Sprintf("missing canonical %s provider ID", e.Provider) + } + return "missing canonical category" +} + +// RequireProviderID returns one canonical provider ID or a typed missing +// requirement error. +func (i ExternalIdentity) RequireProviderID(provider IdentityProvider) (int, error) { + id, ok := i.ProviderID(provider) + if !ok { + return 0, &MissingRequirementError{Requirement: RequirementKindProviderID, Provider: provider} + } + return id, nil +} + +// RequireCategory returns the canonical category or a typed missing +// requirement error. +func (i ExternalIdentity) RequireCategory() (CanonicalCategory, error) { + category, err := NormalizeCanonicalCategory(string(i.Category)) + if err != nil { + return CanonicalCategoryUnknown, &MissingRequirementError{Requirement: RequirementKindCategory} + } + switch category { + case CanonicalCategoryMovie, CanonicalCategoryTV: + return category, nil + case CanonicalCategoryUnknown: + return CanonicalCategoryUnknown, &MissingRequirementError{Requirement: RequirementKindCategory} + } + return CanonicalCategoryUnknown, &MissingRequirementError{Requirement: RequirementKindCategory} +} + +// SourceScopedMetadata stores optional provider enrichment for one source and +// prepared generation. +type SourceScopedMetadata struct { + SourcePath string + Generation PreparedGeneration + TMDB *TMDBMetadata + IMDB *IMDBMetadata + TVDB *TVDBMetadata + TVmaze *TVmazeMetadata + AniList *AniListMetadata + Bluray *BlurayMetadata + UpdatedAt time.Time `ts_type:"string"` +} + +// DiagnosticSeverity classifies preparation diagnostics without turning them +// into workflow outcomes. +type DiagnosticSeverity string + +const ( + // DiagnosticSeverityInfo records explanatory evidence. + DiagnosticSeverityInfo DiagnosticSeverity = "info" + // DiagnosticSeverityWarning records a non-fatal preparation concern. + DiagnosticSeverityWarning DiagnosticSeverity = "warning" +) + +// PreparationDiagnostic is non-canonical evidence or warning returned beside +// a prepared release. +type PreparationDiagnostic struct { + Code string + Severity DiagnosticSeverity + Message string + Candidates []ExternalIdentityCandidate +} + +// ExternalIdentityCandidate is possible provider evidence exposed for review, +// never a canonical external-identity fact. +type ExternalIdentityCandidate struct { + Provider IdentityProvider + ID int + Title string + OriginalTitle string + Year int + Category CanonicalCategory + MediaType string + Overview string + PosterURL string + Similarity float64 +} + +// Clone returns a detached prepared-release projection. +func (r PreparedRelease) Clone() (PreparedRelease, error) { + return clonePreparedValue(r) +} + +// Clone returns a detached result whose release and diagnostics can be mutated +// by the caller without affecting module-owned state. +func (r PrepareResult) Clone() (PrepareResult, error) { + return clonePreparedValue(r) +} + +func clonePreparedValue[T any](value T) (T, error) { + var cloned T + payload, err := json.Marshal(value) + if err != nil { + return cloned, fmt.Errorf("clone prepared value: marshal: %w", err) + } + if err := json.Unmarshal(payload, &cloned); err != nil { + return cloned, fmt.Errorf("clone prepared value: unmarshal: %w", err) + } + return cloned, nil +} + +// NormalizeCanonicalCategory maps supported legacy/user labels to a canonical +// movie, TV, or explicit unknown value. +func NormalizeCanonicalCategory(value string) (CanonicalCategory, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "unknown": + return CanonicalCategoryUnknown, nil + case "movie", "film": + return CanonicalCategoryMovie, nil + case "tv", "television", "series", "episode": + return CanonicalCategoryTV, nil + default: + return CanonicalCategoryUnknown, errors.New("unsupported canonical category") + } +} diff --git a/pkg/api/prepared_release_test.go b/pkg/api/prepared_release_test.go new file mode 100644 index 00000000..9de7e3eb --- /dev/null +++ b/pkg/api/prepared_release_test.go @@ -0,0 +1,107 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "errors" + "testing" +) + +func TestPreparedReleaseCloneDetachesCollections(t *testing.T) { + source := PreparedRelease{ + Source: SourceManifest{ + SourcePath: "Example.Release.2026.1080p-GRP.mkv", + Entries: []SourceManifestEntry{ + { +Path: "Example.Release.2026.1080p-GRP.mkv", + Type: SourceEntryTypeFile, + Size: 42, +}, + }, + }, + Naming: NamingFacts{Codecs: []string{"H.264"}}, + ProviderMetadata: SourceScopedMetadata{ + TMDB: &TMDBMetadata{LocalizedTitles: map[string]string{"en": "Example Release 2026"}}, + }, + Assessments: ReleaseAssessments{ + Naming: NamingAssessment{Status: NamingStatusIncomplete, Missing: []NamingRequirement{"year"}}, + }, + } + + cloned, err := source.Clone() + if err != nil { + t.Fatalf("clone: %v", err) + } + cloned.Source.Entries[0].Path = "changed.mkv" + cloned.Naming.Codecs[0] = "changed" + cloned.ProviderMetadata.TMDB.LocalizedTitles["en"] = "changed" + cloned.Assessments.Naming.Missing[0] = "changed" + + if source.Source.Entries[0].Path == cloned.Source.Entries[0].Path { + t.Fatal("source entries share storage") + } + if source.Naming.Codecs[0] == cloned.Naming.Codecs[0] { + t.Fatal("naming codecs share storage") + } + if source.ProviderMetadata.TMDB.LocalizedTitles["en"] == cloned.ProviderMetadata.TMDB.LocalizedTitles["en"] { + t.Fatal("provider metadata shares storage") + } + if source.Assessments.Naming.Missing[0] == cloned.Assessments.Naming.Missing[0] { + t.Fatal("naming assessment shares storage") + } +} + +func TestExternalIdentityRequirementsUseCanonicalFieldsOnly(t *testing.T) { + identity := ExternalIdentity{ + TMDBID: 123456, + Category: CanonicalCategoryMovie, + } + + id, err := identity.RequireProviderID(IdentityProviderTMDB) + if err != nil || id != 123456 { + t.Fatalf("TMDB requirement = (%d, %v)", id, err) + } + category, err := identity.RequireCategory() + if err != nil || category != CanonicalCategoryMovie { + t.Fatalf("category requirement = (%q, %v)", category, err) + } + + _, err = identity.RequireProviderID(IdentityProviderIMDB) + assertMissingRequirement(t, err, RequirementKindProviderID, IdentityProviderIMDB) + _, err = (ExternalIdentity{Category: CanonicalCategoryUnknown}).RequireCategory() + assertMissingRequirement(t, err, RequirementKindCategory, "") + _, err = (ExternalIdentity{Category: "documentary"}).RequireCategory() + assertMissingRequirement(t, err, RequirementKindCategory, "") +} + +func TestNormalizeCanonicalCategory(t *testing.T) { + tests := map[string]CanonicalCategory{ + "": CanonicalCategoryUnknown, + "unknown": CanonicalCategoryUnknown, + "MOVIE": CanonicalCategoryMovie, + "film": CanonicalCategoryMovie, + "TV": CanonicalCategoryTV, + "episode": CanonicalCategoryTV, + } + for input, want := range tests { + got, err := NormalizeCanonicalCategory(input) + if err != nil || got != want { + t.Fatalf("NormalizeCanonicalCategory(%q) = (%q, %v), want %q", input, got, err, want) + } + } + if _, err := NormalizeCanonicalCategory("unsupported"); err == nil { + t.Fatal("unsupported category succeeded") + } +} + +func assertMissingRequirement(t *testing.T, err error, requirement RequirementKind, provider IdentityProvider) { + t.Helper() + var missing *MissingRequirementError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want MissingRequirementError", err) + } + if missing.Requirement != requirement || missing.Provider != provider { + t.Fatalf("missing requirement = %#v", missing) + } +} diff --git a/pkg/api/preview.go b/pkg/api/preview.go index 2c69e6eb..d0c20082 100644 --- a/pkg/api/preview.go +++ b/pkg/api/preview.go @@ -15,24 +15,22 @@ type MetadataPreview struct { SourcePath string TrackerName string ReleaseName string - Warnings []string ReleaseNameOverrides ReleaseNameOverrides - ExternalIDs ExternalIDs - ExternalIDCandidates ExternalIDCandidates - // ExternalIDInfo includes resolved IDs even when no metadata was fetched from that provider. - ExternalIDInfo []ExternalIDInfo - // ExternalPreview includes only providers whose metadata payload was fetched and populated. - ExternalPreview []ExternalPreview - Bluray *BlurayMetadata - TrackerData []TrackerPreview + Release ReleaseRef + Identity ExternalIdentity + Display PreparedReleaseDisplay + Bluray *BlurayMetadata + Diagnostics []PreparationDiagnostic + TrackerData []TrackerPreview // TrackerRuleFailures is keyed by normalized tracker code and contains // upload rule failures known at preview time. TrackerRuleFailures map[string][]RuleFailure } type DescriptionBuilderPreview struct { - SourcePath string - Groups []DescriptionBuilderGroup + SourcePath string + Groups []DescriptionBuilderGroup + ContentFailures []TrackerContentFailure } type DescriptionBuilderGroup struct { @@ -47,8 +45,9 @@ type DescriptionBuilderGroup struct { } type PreparationPreview struct { - SourcePath string - Descriptions []PreparationDescription + SourcePath string + Descriptions []PreparationDescription + ContentFailures []TrackerContentFailure } type TrackerDryRunPreview struct { @@ -57,8 +56,9 @@ type TrackerDryRunPreview struct { } type UploadReview struct { - SourcePath string - Trackers []TrackerReview + SourcePath string + Trackers []TrackerReview + Eligibility TrackerEligibility } type TrackerReview struct { @@ -96,9 +96,19 @@ type TrackerDryRunEntry struct { Files []TrackerDryRunFile // DebugSections carries optional staged diagnostics for trackers whose dry-run // preview needs to show more than one request or derived payload. - DebugSections []TrackerDryRunDebugSection - Questionnaire *TrackerQuestionnaire - ImageHost ImageHostFeedback + DebugSections []TrackerDryRunDebugSection + Questionnaire *TrackerQuestionnaire + ImageHost ImageHostFeedback + ContentFailure *TrackerContentFailure + Diagnostics TrackerDryRunDiagnostics +} + +// TrackerDryRunDiagnostics describes live-upload findings without changing +// dry-run payload readiness. +type TrackerDryRunDiagnostics struct { + RuleDecisions []RuleDecision + Duplicate DupeCheckResult + LiveEligibilityReasons []TrackerEligibilityReason } // TrackerDryRunDebugSection describes one named diagnostic payload inside a @@ -172,41 +182,3 @@ type ExternalIDInfo struct { ID int Source string } - -// ExternalPreview contains fetched metadata for one resolved external provider ID. -// A resolved ID without provider metadata appears in [MetadataPreview.ExternalIDInfo] only. -type ExternalPreview struct { - Provider string - ID int - Source string - Title string - Year int - Overview string - PosterURL string - BackdropURL string - Category string - OriginalTitle string - ReleaseDate string - FirstAirDate string - LastAirDate string - OriginalLanguage string - TMDBType string - Runtime int - Genres string - Keywords string - YouTube string - IMDBType string - Rating float64 - RatingCount int - RuntimeMinutes int - Country string - Premiered string - IMDBID int - TVDBID int - TMDB *TMDBMetadata - IMDB *IMDBMetadata - TVDB *TVDBMetadata - TVmaze *TVmazeMetadata - // AniList contains rich preview metadata when Provider is "mal". - AniList *AniListMetadata -} diff --git a/pkg/api/repository.go b/pkg/api/repository.go index 394ca9d8..bd8fb0f0 100644 --- a/pkg/api/repository.go +++ b/pkg/api/repository.go @@ -8,6 +8,7 @@ import ( "database/sql/driver" "errors" "fmt" + "reflect" "strings" "time" ) @@ -143,10 +144,8 @@ type TrackerMetadata struct { IMDBID int TVDBID int MALID int - // Category is the site-reported movie/TV content category from the tracker - // API. Supported values take precedence over MediaInfoCategory and - // Release.Category when resolving ExternalIDs.Category for upload - // classification; unsupported categories are ignored. + // Category is site-reported movie/TV evidence consumed only by canonical + // external-identity resolution; unsupported values are ignored. Category Category Description string ImageURLs []string @@ -172,9 +171,11 @@ type TrackerRuleFailure struct { Tracker string Rule string Reason string - // Severity defaults to blocking for records created before severity was stored. - Severity RuleFailureSeverity - CreatedAt time.Time `ts_type:"string"` + // Disposition is normalized from legacy severity values during migration/readback. + Disposition RuleDisposition + // Authorized records whether the exact waivable result was accepted for the stored operation. + Authorized bool + CreatedAt time.Time `ts_type:"string"` } type DescriptionOverride struct { @@ -254,36 +255,92 @@ type DVDMediaInfo struct { UpdatedAt time.Time `ts_type:"string"` } -type MetadataRepository interface { +// ReleaseStateRepository persists the state used to prepare one release. +// Implementations preserve path validation, UTC timestamps, and ErrNotFound +// identity for missing optional records. +type ReleaseStateRepository interface { GetByPath(ctx context.Context, path string) (FileMetadata, error) Save(ctx context.Context, metadata FileMetadata) error - GetExternalIDs(ctx context.Context, path string) (ExternalIDs, error) - SaveExternalIDs(ctx context.Context, ids ExternalIDs) error - GetExternalMetadata(ctx context.Context, path string) (ExternalMetadata, error) - SaveExternalMetadata(ctx context.Context, metadata ExternalMetadata) error - GetDVDMediaInfo(ctx context.Context, path string) (DVDMediaInfo, error) + GetExternalIdentity(ctx context.Context, path string) (ExternalIdentity, error) + SaveExternalIdentity(ctx context.Context, ids ExternalIdentity) error + GetExternalMetadata(ctx context.Context, path string) (SourceScopedMetadata, error) + SaveExternalMetadata(ctx context.Context, metadata SourceScopedMetadata) error SaveDVDMediaInfo(ctx context.Context, info DVDMediaInfo) error GetReleaseNameOverrides(ctx context.Context, path string) (ReleaseNameOverrides, error) SaveReleaseNameOverrides(ctx context.Context, path string, overrides ReleaseNameOverrides) error - DeleteReleaseNameOverrides(ctx context.Context, path string) error +} + +// PreparedReleaseRepository owns whole-generation prepared facts. Commits and +// purges include canonical identity and source-scoped provider metadata in one +// transaction. +type PreparedReleaseRepository interface { + LoadPreparedRelease(ctx context.Context, sourcePath string) (PreparedRelease, error) + CommitPreparedRelease(ctx context.Context, release PreparedRelease) error + PurgePreparedRelease(ctx context.Context, sourcePath string) error +} + +// ReleaseSelectionRepository persists user-selected description and playlist +// state. Explicit empty selections are distinct from missing records. +type ReleaseSelectionRepository interface { GetDescriptionOverride(ctx context.Context, path string, groupKey string) (DescriptionOverride, error) ListDescriptionOverridesByPath(ctx context.Context, path string) ([]DescriptionOverride, error) SaveDescriptionOverride(ctx context.Context, override DescriptionOverride) error DeleteDescriptionOverride(ctx context.Context, path string, groupKey string) error GetPlaylistSelection(ctx context.Context, sourcePath string) (PlaylistSelection, error) SavePlaylistSelection(ctx context.Context, sourcePath string, playlists []string, useAll bool) error - DeletePlaylistSelection(ctx context.Context, sourcePath string) error +} + +// HistoryCleanupSnapshot contains persisted local paths needed by Core's +// filesystem cleanup policy. ArtifactPaths is an isolated caller-owned slice. +type HistoryCleanupSnapshot struct { + Metadata *FileMetadata + ArtifactPaths []string +} + +// HistoryRepository owns persisted history projection, cleanup discovery, and +// atomic release-state purge. Filesystem deletion remains outside this seam. +type HistoryRepository interface { ListHistoryEntries(ctx context.Context) ([]HistoryEntry, error) + LoadHistoryRecord(ctx context.Context, sourcePath string) (HistoryRecord, error) + LoadHistoryCleanupSnapshot(ctx context.Context, sourcePath string) (HistoryCleanupSnapshot, error) + ListStoredReleasePaths(ctx context.Context) ([]string, error) + PurgeContentData(ctx context.Context, path string) error +} + +// UploadLedgerRepository owns upload-attempt creation, latest-record status +// transitions, and canonical newest-first history queries. +type UploadLedgerRepository interface { ListUploadHistoryByPath(ctx context.Context, sourcePath string) ([]UploadRecord, error) - ListPendingUploads(ctx context.Context) ([]UploadRecord, error) CreateUploadRecord(ctx context.Context, record UploadRecord) error UpdateLatestUploadRecordStatus(ctx context.Context, sourcePath string, tracker string, status string) error +} + +// TrackerStateRepository persists tracker-derived metadata, refresh times, and +// atomic replacement of rule-failure sets. +type TrackerStateRepository interface { SaveTrackerRuleFailures(ctx context.Context, sourcePath string, tracker string, failures []TrackerRuleFailure) error ListTrackerRuleFailuresByPath(ctx context.Context, path string) ([]TrackerRuleFailure, error) GetTrackerTimestamp(ctx context.Context, tracker string) (time.Time, error) SaveTrackerTimestamp(ctx context.Context, timestamp TrackerTimestamp) error SaveTrackerMetadata(ctx context.Context, metadata TrackerMetadata) error ListTrackerMetadataByPath(ctx context.Context, path string) ([]TrackerMetadata, error) +} + +// MediaAssetSnapshot is a coherent caller-owned view of persisted media +// records for one release. Returned slices and nested variant slices may be +// mutated by callers without changing repository state. +type MediaAssetSnapshot struct { + Screenshots []Screenshot + FinalSelections []ScreenshotFinalSelection + ScreenshotSlots []ScreenshotSlot + UploadedImages []UploadedImageLink +} + +// MediaAssetRepository persists screenshot, selection, slot, variant, and +// uploaded-image records. Screenshot lifecycle mutations are atomic. +type MediaAssetRepository interface { + ScreenshotLifecycleRepository + LoadMediaAssetSnapshot(ctx context.Context, path string) (MediaAssetSnapshot, error) SaveScreenshot(ctx context.Context, screenshot Screenshot) error ListScreenshotsByPath(ctx context.Context, path string) ([]Screenshot, error) DeleteScreenshot(ctx context.Context, imagePath string) error @@ -296,6 +353,125 @@ type MetadataRepository interface { SaveUploadedImages(ctx context.Context, path string, host string, images []UploadedImageLink) error ListUploadedImagesByPath(ctx context.Context, path string) ([]UploadedImageLink, error) DeleteUploadedImage(ctx context.Context, path string, imagePath string, host string) error - ListStoredReleasePaths(ctx context.Context) ([]string, error) - PurgeContentData(ctx context.Context, path string) error +} + +var ( + // ErrMissingReleaseStateRepository indicates incomplete repository composition. + ErrMissingReleaseStateRepository = errors.New("api: release state repository is required") + // ErrMissingPreparedReleaseRepository indicates incomplete prepared-generation persistence. + ErrMissingPreparedReleaseRepository = errors.New("api: prepared release repository is required") + // ErrMissingReleaseSelectionRepository indicates incomplete repository composition. + ErrMissingReleaseSelectionRepository = errors.New("api: release selection repository is required") + // ErrMissingHistoryRepository indicates incomplete repository composition. + ErrMissingHistoryRepository = errors.New("api: history repository is required") + // ErrMissingUploadLedgerRepository indicates incomplete repository composition. + ErrMissingUploadLedgerRepository = errors.New("api: upload ledger repository is required") + // ErrMissingTrackerStateRepository indicates incomplete repository composition. + ErrMissingTrackerStateRepository = errors.New("api: tracker state repository is required") + // ErrMissingMediaAssetRepository indicates incomplete repository composition. + ErrMissingMediaAssetRepository = errors.New("api: media asset repository is required") +) + +// RepositoryCapabilities is an immutable set of borrowed persistence +// capabilities. Construct it from one adapter so production capabilities share +// connection, retry, transaction, and lifecycle ownership. +type RepositoryCapabilities struct { + releaseState ReleaseStateRepository + prepared PreparedReleaseRepository + selections ReleaseSelectionRepository + history HistoryRepository + uploads UploadLedgerRepository + trackers TrackerStateRepository + media MediaAssetRepository +} + +// RepositoryCapabilitiesFrom projects one adapter onto every repository seam. +// Call [RepositoryCapabilities.Validate] before installing the result. +func RepositoryCapabilitiesFrom(adapter any) RepositoryCapabilities { + releaseState, _ := adapter.(ReleaseStateRepository) + prepared, _ := adapter.(PreparedReleaseRepository) + selections, _ := adapter.(ReleaseSelectionRepository) + history, _ := adapter.(HistoryRepository) + uploads, _ := adapter.(UploadLedgerRepository) + trackers, _ := adapter.(TrackerStateRepository) + media, _ := adapter.(MediaAssetRepository) + return RepositoryCapabilities{ + releaseState: releaseState, + prepared: prepared, + selections: selections, + history: history, + uploads: uploads, + trackers: trackers, + media: media, + } +} + +// NewRepositoryCapabilities constructs and validates borrowed capabilities +// projected from one adapter. +func NewRepositoryCapabilities(adapter any) (RepositoryCapabilities, error) { + capabilities := RepositoryCapabilitiesFrom(adapter) + if err := capabilities.Validate(); err != nil { + return RepositoryCapabilities{}, err + } + return capabilities, nil +} + +// Validate rejects missing and typed-nil capabilities before runtime use. +func (c RepositoryCapabilities) Validate() error { + checks := []struct { + value any + err error + }{ + {value: c.releaseState, err: ErrMissingReleaseStateRepository}, + {value: c.prepared, err: ErrMissingPreparedReleaseRepository}, + {value: c.selections, err: ErrMissingReleaseSelectionRepository}, + {value: c.history, err: ErrMissingHistoryRepository}, + {value: c.uploads, err: ErrMissingUploadLedgerRepository}, + {value: c.trackers, err: ErrMissingTrackerStateRepository}, + {value: c.media, err: ErrMissingMediaAssetRepository}, + } + for _, check := range checks { + if isNilRepositoryCapability(check.value) { + return check.err + } + } + return nil +} + +// IsZero reports whether no repository capabilities were supplied. +func (c RepositoryCapabilities) IsZero() bool { + return c.releaseState == nil && c.prepared == nil && c.selections == nil && c.history == nil && c.uploads == nil && c.trackers == nil && c.media == nil +} + +// ReleaseState returns the borrowed release-state capability. +func (c RepositoryCapabilities) ReleaseState() ReleaseStateRepository { return c.releaseState } + +// Prepared returns the borrowed whole-generation prepared-release capability. +func (c RepositoryCapabilities) Prepared() PreparedReleaseRepository { return c.prepared } + +// Selections returns the borrowed release-selection capability. +func (c RepositoryCapabilities) Selections() ReleaseSelectionRepository { return c.selections } + +// History returns the borrowed history capability. +func (c RepositoryCapabilities) History() HistoryRepository { return c.history } + +// Uploads returns the borrowed upload-ledger capability. +func (c RepositoryCapabilities) Uploads() UploadLedgerRepository { return c.uploads } + +// Trackers returns the borrowed tracker-state capability. +func (c RepositoryCapabilities) Trackers() TrackerStateRepository { return c.trackers } + +// Media returns the borrowed media-asset capability. +func (c RepositoryCapabilities) Media() MediaAssetRepository { return c.media } + +func isNilRepositoryCapability(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + kind := reflected.Kind() + if kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice { + return reflected.IsNil() + } + return false } diff --git a/pkg/api/repository_capabilities_test.go b/pkg/api/repository_capabilities_test.go new file mode 100644 index 00000000..caa6f090 --- /dev/null +++ b/pkg/api/repository_capabilities_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +type nilReleaseStateRepository struct{} + +func (*nilReleaseStateRepository) GetByPath(context.Context, string) (FileMetadata, error) { + return FileMetadata{}, nil +} +func (*nilReleaseStateRepository) Save(context.Context, FileMetadata) error { return nil } +func (*nilReleaseStateRepository) GetExternalIdentity(context.Context, string) (ExternalIdentity, error) { + return ExternalIdentity{}, nil +} +func (*nilReleaseStateRepository) SaveExternalIdentity(context.Context, ExternalIdentity) error { + return nil +} +func (*nilReleaseStateRepository) GetExternalMetadata(context.Context, string) (SourceScopedMetadata, error) { + return SourceScopedMetadata{}, nil +} +func (*nilReleaseStateRepository) SaveExternalMetadata(context.Context, SourceScopedMetadata) error { + return nil +} +func (*nilReleaseStateRepository) GetDVDMediaInfo(context.Context, string) (DVDMediaInfo, error) { + return DVDMediaInfo{}, nil +} +func (*nilReleaseStateRepository) SaveDVDMediaInfo(context.Context, DVDMediaInfo) error { return nil } +func (*nilReleaseStateRepository) GetReleaseNameOverrides(context.Context, string) (ReleaseNameOverrides, error) { + return ReleaseNameOverrides{}, nil +} +func (*nilReleaseStateRepository) SaveReleaseNameOverrides(context.Context, string, ReleaseNameOverrides) error { + return nil +} +func (*nilReleaseStateRepository) DeleteReleaseNameOverrides(context.Context, string) error { + return nil +} + +func TestRepositoryCapabilitiesRejectMissingAndTypedNil(t *testing.T) { + t.Parallel() + + if err := (RepositoryCapabilities{}).Validate(); !errors.Is(err, ErrMissingReleaseStateRepository) { + t.Fatalf("zero bundle error = %v, want %v", err, ErrMissingReleaseStateRepository) + } + var typedNil *nilReleaseStateRepository + capabilities := RepositoryCapabilities{releaseState: typedNil} + if err := capabilities.Validate(); !errors.Is(err, ErrMissingReleaseStateRepository) { + t.Fatalf("typed-nil bundle error = %v, want %v", err, ErrMissingReleaseStateRepository) + } +} + +func TestRepositoryCapabilityInterfacesDoNotExposeLifecycleOrSQL(t *testing.T) { + t.Parallel() + + interfaces := []reflect.Type{ + reflect.TypeFor[ReleaseStateRepository](), + reflect.TypeFor[PreparedReleaseRepository](), + reflect.TypeFor[ReleaseSelectionRepository](), + reflect.TypeFor[HistoryRepository](), + reflect.TypeFor[UploadLedgerRepository](), + reflect.TypeFor[TrackerStateRepository](), + reflect.TypeFor[MediaAssetRepository](), + reflect.TypeFor[ScreenshotLifecycleRepository](), + } + for _, interfaceType := range interfaces { + if _, ok := interfaceType.MethodByName("Close"); ok { + t.Errorf("%s exposes Close", interfaceType) + } + for method := range interfaceType.Methods() { + if strings.Contains(method.Type.String(), "database/sql") { + t.Errorf("%s.%s exposes SQL type %s", interfaceType, method.Name, method.Type) + } + } + } +} diff --git a/pkg/api/repository_test.go b/pkg/api/repository_test.go index de5b3768..d2981bd5 100644 --- a/pkg/api/repository_test.go +++ b/pkg/api/repository_test.go @@ -12,12 +12,42 @@ func TestNormalizeCategory(t *testing.T) { want Category valid bool }{ - {name: "movie", input: "movie", want: CategoryMovie, valid: true}, - {name: "film alias", input: "Film", want: CategoryMovie, valid: true}, - {name: "tv series alias", input: "tv-show", want: CategoryTV, valid: true}, - {name: "episode alias", input: "episode", want: CategoryTV, valid: true}, - {name: "empty", input: "", want: CategoryUnknown, valid: false}, - {name: "unsupported", input: "Music", want: Category("Music"), valid: false}, + { + name: "movie", + input: "movie", + want: CategoryMovie, + valid: true, + }, + { + name: "film alias", + input: "Film", + want: CategoryMovie, + valid: true, + }, + { + name: "tv series alias", + input: "tv-show", + want: CategoryTV, + valid: true, + }, + { + name: "episode alias", + input: "episode", + want: CategoryTV, + valid: true, + }, + { + name: "empty", + input: "", + want: CategoryUnknown, + valid: false, + }, + { + name: "unsupported", + input: "Music", + want: Category("Music"), + valid: false, + }, } for _, tc := range tests { diff --git a/pkg/api/request_mapping.go b/pkg/api/request_mapping.go new file mode 100644 index 00000000..11051e48 --- /dev/null +++ b/pkg/api/request_mapping.go @@ -0,0 +1,146 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "errors" + "fmt" + "maps" + "strings" +) + +// ErrPreparationSourceRequired reports a request without a preparation source. +var ErrPreparationSourceRequired = errors.New("preparation source path is required") + +// MapPreparationRequest maps one shared operation request to detached canonical +// preparation input. It performs no filesystem access or entrypoint-specific +// policy. +func MapPreparationRequest(request Request, intent PreparationIntent) (PrepareInput, error) { + sourcePath := strings.TrimSpace(request.SourcePath) + if sourcePath == "" { + return PrepareInput{}, ErrPreparationSourceRequired + } + + input := PrepareInput{ + SourcePath: sourcePath, + Intent: intent, + Instructions: ReleaseFactInstructions{ + Identity: cloneExternalIDOverrides(request.ExternalIDOverrides), + ReleaseName: cloneReleaseNameOverrides(request.ReleaseNameOverrides), + Metadata: cloneMetadataOverrides(request.MetadataOverrides), + SourceLookup: strings.TrimSpace(request.SourceLookupURL), + TrackerIDs: cloneStringMap(request.TrackerIDOverrides), + Playlist: PlaylistInstruction{ + Set: request.PlaylistInstruction.Set, + Selected: append([]string(nil), request.PlaylistInstruction.Selected...), + UseAll: request.PlaylistInstruction.UseAll, + }, + }, + Policy: PreparationPolicy{ + KeepFolder: request.Options.KeepFolder, + KeepImages: request.Options.KeepImages, + OnlyID: request.Options.OnlyID, + }, + Search: ClientSearchPolicy{ + Skip: request.Options.SkipAutoTorrent, + Client: cloneString(request.ClientOverrides.Client), + }, + Controls: PreparationControls{ + Interaction: request.Options.InteractionMode, + ConfirmBDMVRescan: request.ConfirmBDMVRescan, + ForceRecheck: cloneBool(request.ClientOverrides.ForceRecheck), + }, + } + if request.ReleaseNameOverrides.Category != nil { + category, err := NormalizeCanonicalCategory(*request.ReleaseNameOverrides.Category) + if err != nil { + return PrepareInput{}, fmt.Errorf("canonical category instruction: %w", err) + } + input.Instructions.Category = &category + } + return input, nil +} + +// cloneStringMap detaches caller-owned map storage before request normalization. +func cloneStringMap(value map[string]string) map[string]string { + if value == nil { + return nil + } + cloned := make(map[string]string, len(value)) + maps.Copy(cloned, value) + return cloned +} + +func cloneExternalIDOverrides(value ExternalIDOverrides) ExternalIDOverrides { + return ExternalIDOverrides{ + TMDBID: cloneInt(value.TMDBID), + IMDBID: cloneInt(value.IMDBID), + TVDBID: cloneInt(value.TVDBID), + TVmazeID: cloneInt(value.TVmazeID), + MALID: cloneInt(value.MALID), + } +} + +func cloneReleaseNameOverrides(value ReleaseNameOverrides) ReleaseNameOverrides { + return ReleaseNameOverrides{ + Category: cloneString(value.Category), + Type: cloneString(value.Type), + Source: cloneString(value.Source), + Resolution: cloneString(value.Resolution), + Tag: cloneString(value.Tag), + Service: cloneString(value.Service), + Edition: cloneString(value.Edition), + Season: cloneString(value.Season), + Episode: cloneString(value.Episode), + EpisodeTitle: cloneString(value.EpisodeTitle), + ManualYear: cloneInt(value.ManualYear), + ManualDate: cloneString(value.ManualDate), + UseSeasonEpisode: cloneBool(value.UseSeasonEpisode), + NoSeason: cloneBool(value.NoSeason), + NoYear: cloneBool(value.NoYear), + NoAKA: cloneBool(value.NoAKA), + NoTag: cloneBool(value.NoTag), + NoEdition: cloneBool(value.NoEdition), + NoDub: cloneBool(value.NoDub), + NoDual: cloneBool(value.NoDual), + DualAudio: cloneBool(value.DualAudio), + Region: cloneString(value.Region), + } +} + +func cloneMetadataOverrides(value MetadataOverrides) MetadataOverrides { + return MetadataOverrides{ + Distributor: cloneString(value.Distributor), + OriginalLanguage: cloneString(value.OriginalLanguage), + PersonalRelease: cloneBool(value.PersonalRelease), + Commentary: cloneBool(value.Commentary), + WebDV: cloneBool(value.WebDV), + StreamOptimized: cloneBool(value.StreamOptimized), + Anime: cloneBool(value.Anime), + } +} + +func cloneInt(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneBool(value *bool) *bool { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/pkg/api/request_mapping_test.go b/pkg/api/request_mapping_test.go new file mode 100644 index 00000000..b7beddbb --- /dev/null +++ b/pkg/api/request_mapping_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +import ( + "errors" + "reflect" + "slices" + "testing" +) + +func TestMapPreparationRequestPreservesSingleSourceIntent(t *testing.T) { + t.Parallel() + + tmdbID := 123456 + category := "TV" + releaseType := "episode" + tag := "GRP" + distributor := "Example Distributor" + personalRelease := false + client := "qbit" + forceRecheck := false + request := Request{ + SourcePath: " Example.Release.2026.mkv ", + Options: UploadOptions{ + KeepFolder: true, + KeepImages: true, + OnlyID: true, + SkipAutoTorrent: true, + InteractionMode: InteractionModeInteractive, + }, + ExternalIDOverrides: ExternalIDOverrides{TMDBID: &tmdbID}, + ReleaseNameOverrides: ReleaseNameOverrides{ + Category: &category, + Type: &releaseType, + Tag: &tag, + }, + MetadataOverrides: MetadataOverrides{ + Distributor: &distributor, + PersonalRelease: &personalRelease, + }, + SourceLookupURL: " https://example.invalid/source ", + TrackerIDOverrides: map[string]string{"btn": "123"}, + PlaylistInstruction: PlaylistInstruction{ + Set: true, + Selected: []string{}, + UseAll: false, + }, + ClientOverrides: ClientOverrides{ + Client: &client, + ForceRecheck: &forceRecheck, + }, + ConfirmBDMVRescan: true, + } + + input, err := MapPreparationRequest(request, PreparationIntentUpload) + if err != nil { + t.Fatalf("map preparation request: %v", err) + } + canonicalCategory := CanonicalCategoryTV + expected := PrepareInput{ + SourcePath: "Example.Release.2026.mkv", + Intent: PreparationIntentUpload, + Instructions: ReleaseFactInstructions{ + Identity: ExternalIDOverrides{TMDBID: new(123456)}, + Category: &canonicalCategory, + ReleaseName: ReleaseNameOverrides{ + Category: new("TV"), + Type: new("episode"), + Tag: new("GRP"), + }, + Metadata: MetadataOverrides{Distributor: new("Example Distributor"), PersonalRelease: new(false)}, + SourceLookup: "https://example.invalid/source", + Playlist: PlaylistInstruction{ + Set: true, + Selected: nil, + UseAll: false, + }, + TrackerIDs: map[string]string{"btn": "123"}, + }, + Policy: PreparationPolicy{ + KeepFolder: true, + KeepImages: true, + OnlyID: true, + }, + Search: ClientSearchPolicy{Skip: true, Client: new("qbit")}, + Controls: PreparationControls{ + Interaction: InteractionModeInteractive, + ConfirmBDMVRescan: true, + ForceRecheck: new(false), + }, + } + if !reflect.DeepEqual(input, expected) { + t.Fatalf("mapped input = %#v, want %#v", input, expected) + } + + *request.ExternalIDOverrides.TMDBID = 999999 + *request.ReleaseNameOverrides.Tag = "CHANGED" + *request.MetadataOverrides.Distributor = "CHANGED" + *request.ClientOverrides.Client = "changed" + request.TrackerIDOverrides["btn"] = "changed" + request.PlaylistInstruction.Selected = append(request.PlaylistInstruction.Selected, "00001.mpls") + if !reflect.DeepEqual(input, expected) { + t.Fatalf("mapped input aliases request state: %#v", input) + } +} + +func TestPrepareInputMappingFieldDispositionIsExplicit(t *testing.T) { + t.Parallel() + + assertFieldNames(t, reflect.TypeFor[PrepareInput](), []string{"SourcePath", "Intent", "Instructions", "Policy", "Search", "Controls", "Force"}) + assertFieldNames(t, reflect.TypeFor[ReleaseFactInstructions](), []string{ + "Identity", "Category", "ReleaseName", "Metadata", "SourceLookup", "BlurayReleaseID", "Playlist", "TrackerIDs", + }) + assertFieldNames(t, reflect.TypeFor[PreparationPolicy](), []string{"KeepFolder", "KeepImages", "OnlyID"}) + assertFieldNames(t, reflect.TypeFor[ClientSearchPolicy](), []string{"Skip", "Client"}) + assertFieldNames(t, reflect.TypeFor[PreparationControls](), []string{"Interaction", "ConfirmBDMVRescan", "ForceRecheck"}) +} + +func TestMapPreparationRequestRejectsBlankSource(t *testing.T) { + t.Parallel() + + _, err := MapPreparationRequest(Request{SourcePath: " "}, PreparationIntentPreview) + if !errors.Is(err, ErrPreparationSourceRequired) { + t.Fatalf("error = %v, want ErrPreparationSourceRequired", err) + } +} + +func assertFieldNames(t *testing.T, value reflect.Type, want []string) { + t.Helper() + got := make([]string, 0, value.NumField()) + for field := range value.Fields() { + got = append(got, field.Name) + } + if !slices.Equal(got, want) { + t.Fatalf("%s fields = %v, want %v", value.Name(), got, want) + } +} diff --git a/pkg/api/screenshots.go b/pkg/api/screenshots.go index 76fd583b..5d4a1b67 100644 --- a/pkg/api/screenshots.go +++ b/pkg/api/screenshots.go @@ -53,6 +53,37 @@ type ScreenshotOverrides struct { MenuPaths []string } +// ScreenshotSubject contains only source facts required to plan and manage +// screenshots. Workflow modules build it from an exact prepared generation. +type ScreenshotSubject struct { + SourcePath string + DiscType string + VideoPath string + MediaInfoJSONPath string + MediaCategory string + HDR string + TVPack bool + Episode int + Release ReleaseInfo + SelectedBDMVPlaylists []PlaylistInfo + DefaultCount int + ManualFrames []int +} + +// DVDMenuSubject contains the stable source facts required by DVD-menu +// capture and lifecycle operations. +type DVDMenuSubject struct { + SourcePath string + DiscType string +} + +// ImageHostingSubject scopes image-host operations to one prepared source. +// GalleryName is the already-resolved display name used by batch hosts. +type ImageHostingSubject struct { + SourcePath string + GalleryName string +} + type ScreenshotFinalSelection struct { SourcePath string ImagePath string diff --git a/pkg/api/services.go b/pkg/api/services.go index 0f93e147..48551188 100644 --- a/pkg/api/services.go +++ b/pkg/api/services.go @@ -13,7 +13,9 @@ import ( ) type ServiceSet struct { - Metadata MetadataService + // Metadata is an internal preparation collector override. Core validates the + // private contract before use; no mutable preparation type is exported here. + Metadata any Trackers TrackerService Torrents TorrentService Clients ClientService @@ -27,32 +29,20 @@ type ServiceSet struct { Images ImageHostingService } -type MetadataService interface { - Prepare(ctx context.Context, req Request) (PreparedMetadata, error) - RefreshPreparedMetadata(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - EnrichTrackerData(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - ApplyMediaInfoIDs(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - ApplyArrData(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - ResolveExternalIDs(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - ApplyMediaDetails(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) - // ApplyTrackerClaims applies claim-based tracker blocks using metadata that - // has already been enriched with media details and tracker rule state. - ApplyTrackerClaims(ctx context.Context, meta PreparedMetadata) (PreparedMetadata, error) -} - type TrackerService interface { - Upload(ctx context.Context, meta PreparedMetadata) (UploadSummary, error) - BuildPreparation(ctx context.Context, meta PreparedMetadata, trackers []string) (PreparationPreview, error) - BuildUploadDryRun(ctx context.Context, meta PreparedMetadata, trackers []string) ([]TrackerDryRunEntry, error) + Upload(ctx context.Context, subject UploadSubject) (UploadSummary, error) + BuildPreparation(ctx context.Context, subject DescriptionSubject, trackers []string) (PreparationPreview, error) + BuildUploadReview(ctx context.Context, subject UploadSubject, trackers []string) ([]TrackerDryRunEntry, error) + BuildUploadDryRun(ctx context.Context, subject UploadSubject, trackers []string) ([]TrackerDryRunEntry, error) } type TorrentService interface { - Create(ctx context.Context, meta PreparedMetadata) (TorrentResult, error) + Create(ctx context.Context, subject TorrentSubject) (TorrentResult, error) } type ClientService interface { - Inject(ctx context.Context, meta PreparedMetadata, torrent TorrentResult) error - SearchPathedTorrents(ctx context.Context, meta PreparedMetadata) (ClientSearchResult, error) + Inject(ctx context.Context, subject ClientSubject, torrent TorrentResult) error + SearchPathedTorrents(ctx context.Context, subject ClientSubject) (ClientSearchResult, error) } type FilesystemService interface { @@ -60,10 +50,53 @@ type FilesystemService interface { } type DupeService interface { - Check(ctx context.Context, meta PreparedMetadata, trackers []string) (DupeCheckSummary, error) -} - -// TrackerAuthService exposes the batch auth operations needed before GUI and + Check(ctx context.Context, subject DuplicateSubject, trackers []string) (DupeCheckSummary, error) +} + +// DuplicateSubject is the duplicate module's source-scoped read model. It +// contains only facts, instructions, and prior workflow outcomes that affect +// duplicate search or authorization. +type DuplicateSubject struct { + SourcePath string + SourceSize int64 + VideoPath string + FileList []string + Filename string + SceneName string + ReleaseName string + Release ReleaseInfo + ReleaseNameOverrides ReleaseNameOverrides + Identity ExternalIdentity + ProviderMetadata SourceScopedMetadata + TrackerIDs map[string]string + DiscType string + Type string + Source string + Tag string + HDR string + UHD string + VideoEncode string + VideoCodec string + HasEncodeSettings bool + SeasonInt int + EpisodeInt int + SeasonStr string + EpisodeStr string + DailyEpisodeDate string + TVPack bool + Anime bool + MatchedTrackers []string + TrackerRuleFailures map[string][]RuleFailure + BlockedTrackers map[string][]TrackerBlockReason + CrossSeedTorrents []UploadedTorrent +} + +// CanonicalSeasonEpisode returns the prepared TV season and episode. +func (s DuplicateSubject) CanonicalSeasonEpisode() (int, int) { + return s.SeasonInt, s.EpisodeInt +} + +// TrackerAuthService exposes the batch auth operations needed before WebUI and // embedded-web duplicate checking. type TrackerAuthService interface { // Capabilities returns the configured trackers whose auth workflows the @@ -75,28 +108,28 @@ type TrackerAuthService interface { } type ScreenshotService interface { - Plan(ctx context.Context, meta PreparedMetadata, count int) (ScreenshotPlan, error) - Capture(ctx context.Context, meta PreparedMetadata, selections []ScreenshotSelection, purpose ScreenshotPurpose) (ScreenshotResult, error) - PreviewFrame(ctx context.Context, meta PreparedMetadata, timestampSeconds float64) (ScreenshotPreview, error) - Delete(ctx context.Context, meta PreparedMetadata, imagePath string) error - SaveFinalSelections(ctx context.Context, meta PreparedMetadata, images []ScreenshotImage) error + Plan(ctx context.Context, subject ScreenshotSubject, count int) (ScreenshotPlan, error) + Capture(ctx context.Context, subject ScreenshotSubject, selections []ScreenshotSelection, purpose ScreenshotPurpose) (ScreenshotResult, error) + PreviewFrame(ctx context.Context, subject ScreenshotSubject, timestampSeconds float64) (ScreenshotPreview, error) + Delete(ctx context.Context, subject ScreenshotSubject, imagePath string) error + SaveFinalSelections(ctx context.Context, subject ScreenshotSubject, images []ScreenshotImage) error } // DVDMenuService captures and manages persisted menu images for prepared DVD metadata. type DVDMenuService interface { // Capture replaces automatic captures up to maxItems while preserving manual menus. - Capture(ctx context.Context, meta PreparedMetadata, maxItems int) (DVDMenuCaptureResult, error) + Capture(ctx context.Context, subject DVDMenuSubject, maxItems int) (DVDMenuCaptureResult, error) // List returns persisted manual and automatic menu images in selection order. - List(ctx context.Context, meta PreparedMetadata) ([]ScreenshotImage, error) + List(ctx context.Context, subject DVDMenuSubject) ([]ScreenshotImage, error) // Delete removes one managed menu image and its local repository references. - Delete(ctx context.Context, meta PreparedMetadata, imagePath string) error + Delete(ctx context.Context, subject DVDMenuSubject, imagePath string) error // Capability reports path-free engine and FFmpeg dvdvideo support. Capability(ctx context.Context) (DVDMenuEngineInfo, error) } type ImageHostingService interface { - ListCandidates(ctx context.Context, meta PreparedMetadata) ([]ScreenshotImage, error) - Upload(ctx context.Context, meta PreparedMetadata, host string, usageScope string, images []ScreenshotImage) ([]UploadedImageLink, error) + ListCandidates(ctx context.Context, subject ImageHostingSubject) ([]ScreenshotImage, error) + Upload(ctx context.Context, subject ImageHostingSubject, host string, usageScope string, images []ScreenshotImage) ([]UploadedImageLink, error) } type TrackerBlockReason string @@ -107,18 +140,11 @@ const ( TrackerBlockReasonAudio TrackerBlockReason = "audio" ) -// PreparedMetadata contains the shared metadata snapshot used across CLI, GUI, -// embedded web, and tracker upload flows. Release preserves parsed release-name -// data; SeasonInt and EpisodeInt carry canonical TV identity after metadata -// parsing and provider/date remapping. -type PreparedMetadata struct { +// TrackerSubject is the tracker module's operation-owned source, resource, +// instruction, and prerequisite view. It excludes preparation diagnostics, +// resolver evidence, cache freshness, and client-search implementation state. +type UploadSubject struct { SourcePath string - SourceLookupURL string - SourceLookupActive bool - SourceLookupMode string - SourceLookupTracker string - SourceLookupTrackerID string - LookupWarnings []string Paths []string DiscType string VideoPath string @@ -126,24 +152,12 @@ type PreparedMetadata struct { SourceSize int64 MediaInfoJSONPath string MediaInfoTextPath string - DVDIFOPath string - DVDVOBPath string - DVDVOBSet string - DVDVOBMediaInfoJSON string DVDVOBMediaInfoText string - MediaInfoUniqueID string Scene bool SceneName string - SceneTMDBID int - SceneIMDB int - SceneTVDBID int - SceneTVmazeID int - SceneMALID int SceneNFOPath string - SceneNFONew bool SceneRenamed bool SceneRenamedReason string - Mode Mode DescriptionGroups []DescriptionBuilderGroup Trackers []string Options UploadOptions @@ -151,46 +165,19 @@ type PreparedMetadata struct { MatchedTrackers []string Tag string Release ReleaseInfo - TagOverride *TagOverride DescriptionOverride string - MetadataOverrides MetadataOverrides TrackerConfigOverrides TrackerConfigOverrides TrackerSiteOverrides TrackerSiteOverrides - ClientOverrides ClientOverrides ImageHostOverrides ImageHostOverrides - ScreenshotOverrides ScreenshotOverrides - TorrentOverrides TorrentOverrides DescriptionTemplate string PersonalRelease bool InfoHash string TrackerIDs map[string]string - FoundTrackerMatch bool - TorrentComments []TorrentMatch - PieceSizeConstraint string - FoundPreferredPiece string - StoredInfoHash string - StoredUpdatedAt time.Time `ts_type:"string"` - StoredDataFresh bool TrackerData []TrackerMetadata CrossSeedTorrents []UploadedTorrent ClientTorrentPath string TorrentPath string - MediaInfoCategory string - MediaInfoTMDBID int - MediaInfoIMDBID int - MediaInfoTVDBID int - ArrSource string - ArrTMDBID int - ArrIMDBID int - ArrTVDBID int - ArrTVmazeID int - ArrYear int - ArrGenres []string ArrReleaseGroup string - MismatchedMediaInfoTMDBID int - MismatchedMediaInfoIMDBID int - MismatchedMediaInfoTVDBID int - ExternalIDOverrides ExternalIDOverrides ReleaseNameOverrides ReleaseNameOverrides TrackerQuestionnaireAnswers map[string]map[string]string SeasonInt int @@ -198,22 +185,17 @@ type PreparedMetadata struct { SeasonStr string EpisodeStr string TVDBAiredDate string - TVDBAirsDays []string TVDBAirsTime string TVDBAirsTimezone string - TVDBAirsTimezoneSource string TVPack bool DailyEpisodeDate string - TMDBDateMatch bool Anime bool - MALID int EpisodeTitle string EpisodeOverview string - EpisodeYear int SelectedBDMVPlaylists []PlaylistInfo - ExternalIDs ExternalIDs - ExternalIDCandidates ExternalIDCandidates - ExternalMetadata ExternalMetadata + Identity ExternalIdentity + ProviderMetadata SourceScopedMetadata + Disc DiscFacts AudioLanguages []string SubtitleLanguages []string Container string @@ -234,8 +216,7 @@ type PreparedMetadata struct { Edition string Repack string WebDV bool - ValidMediaInfo bool - ValidMediaInfoSettings bool + Assessments ReleaseAssessments StreamOptimized int Service string ServiceLongName string @@ -243,40 +224,154 @@ type PreparedMetadata struct { ReleaseName string ReleaseNameNoTag string ReleaseNameClean string - ReleaseNameMissing []string BlockedTrackers map[string][]TrackerBlockReason - IgnoreTrackerRuleFailures bool TrackerRuleFailures map[string][]RuleFailure - BDInfo map[string]any } -// CanonicalSeasonEpisode returns the provider-resolved TV season/episode used -// for upload identity and naming. -func (m PreparedMetadata) CanonicalSeasonEpisode() (int, int) { - return m.SeasonInt, m.EpisodeInt -} - -// SeasonEpisodeWithParsedFallback returns canonical season/episode values and -// falls back to release-name parsed values independently for each missing -// field. The returned season and episode can come from different sources, so -// callers must not treat the pair as source-consistent. Use this for TV -// classification and lookup fallbacks, not for canonical upload naming. -func (m PreparedMetadata) SeasonEpisodeWithParsedFallback() (int, int) { - season, episode := m.CanonicalSeasonEpisode() - if season <= 0 { - season = m.Release.Season +// RuleSubject contains only stable facts used by generic and tracker-specific +// eligibility rules. +type RuleSubject struct { + SourcePath string + VideoPath string + FileList []string + DiscType string + Scene bool + SceneNFOPath string + SceneRenamed bool + SceneRenamedReason string + PersonalRelease bool + Release ReleaseInfo + ReleaseName string + ReleaseNameNoTag string + Tag string + Identity ExternalIdentity + ProviderMetadata SourceScopedMetadata + AudioLanguages []string + SubtitleLanguages []string + TVPack bool + Type string + Source string + Container string + BitDepth string + VideoCodec string + VideoEncode string + HDR string + Region string + WebDV bool + Anime bool + Assessments ReleaseAssessments + DescriptionOverride string +} + +// NewRuleSubject projects upload facts into the rule evaluator's read model. +func NewRuleSubject(subject UploadSubject) RuleSubject { + return RuleSubject{ + SourcePath: subject.SourcePath, + VideoPath: subject.VideoPath, + FileList: append([]string(nil), subject.FileList...), + DiscType: subject.DiscType, + Scene: subject.Scene, + SceneNFOPath: subject.SceneNFOPath, + SceneRenamed: subject.SceneRenamed, + SceneRenamedReason: subject.SceneRenamedReason, + PersonalRelease: subject.PersonalRelease, + Release: subject.Release, + ReleaseName: subject.ReleaseName, + ReleaseNameNoTag: subject.ReleaseNameNoTag, + Tag: subject.Tag, + Identity: subject.Identity, + ProviderMetadata: subject.ProviderMetadata, + AudioLanguages: append([]string(nil), subject.AudioLanguages...), + SubtitleLanguages: append([]string(nil), subject.SubtitleLanguages...), + TVPack: subject.TVPack, + Type: subject.Type, + Source: subject.Source, + Container: subject.Container, + BitDepth: subject.BitDepth, + VideoCodec: subject.VideoCodec, + VideoEncode: subject.VideoEncode, + HDR: subject.HDR, + Region: subject.Region, + WebDV: subject.WebDV, + Anime: subject.Anime, + Assessments: subject.Assessments, + DescriptionOverride: subject.DescriptionOverride, } - if episode <= 0 { - episode = m.Release.Episode +} + +// DescriptionSubject contains only facts, local resources, and rendering +// instructions consumed by tracker description builders. +type DescriptionSubject struct { + SourcePath string + DiscType string + MediaInfoTextPath string + DVDVOBMediaInfoText string + DescriptionTemplate string + EpisodeOverview string + Options UploadOptions + Release ReleaseInfo + SelectedBDMVPlaylists []PlaylistInfo + Tag string + Identity ExternalIdentity + ProviderMetadata SourceScopedMetadata + SeasonInt int + EpisodeInt int + Filename string + ReleaseName string + ReleaseNameNoTag string + ServiceLongName string + Type string + HDR string + ArrReleaseGroup string + Trackers []string + TrackerConfig TrackerConfigOverrides + TrackerSite TrackerSiteOverrides + ImageHost ImageHostOverrides + TrackerData []TrackerMetadata +} + +// NewDescriptionSubject projects upload state into the description builder's +// read model and detaches mutable collections. +func NewDescriptionSubject(subject UploadSubject) DescriptionSubject { + projected := DescriptionSubject{ + SourcePath: subject.SourcePath, + DiscType: subject.DiscType, + MediaInfoTextPath: subject.MediaInfoTextPath, + DVDVOBMediaInfoText: subject.DVDVOBMediaInfoText, + DescriptionTemplate: subject.DescriptionTemplate, + EpisodeOverview: subject.EpisodeOverview, + Options: subject.Options, + Release: subject.Release, + SelectedBDMVPlaylists: append([]PlaylistInfo(nil), subject.SelectedBDMVPlaylists...), + Tag: subject.Tag, + Identity: subject.Identity, + ProviderMetadata: subject.ProviderMetadata, + SeasonInt: subject.SeasonInt, + EpisodeInt: subject.EpisodeInt, + Filename: subject.Filename, + ReleaseName: subject.ReleaseName, + ReleaseNameNoTag: subject.ReleaseNameNoTag, + ServiceLongName: subject.ServiceLongName, + Type: subject.Type, + HDR: subject.HDR, + ArrReleaseGroup: subject.ArrReleaseGroup, + Trackers: append([]string(nil), subject.Trackers...), + TrackerConfig: subject.TrackerConfigOverrides, + TrackerSite: subject.TrackerSiteOverrides, + ImageHost: subject.ImageHostOverrides, + TrackerData: append([]TrackerMetadata(nil), subject.TrackerData...), } - return season, episode + cloned, err := clonePreparedValue(projected) + if err != nil { + panic(fmt.Sprintf("clone description subject: %v", err)) + } + return cloned } -// HasTVSeasonEpisodeSignal reports whether either canonical metadata or the -// parsed release name carries a season/episode hint. -func (m PreparedMetadata) HasTVSeasonEpisodeSignal() bool { - season, episode := m.SeasonEpisodeWithParsedFallback() - return season > 0 || episode > 0 +// CanonicalSeasonEpisode returns the provider-resolved TV season/episode used +// by tracker operations. +func (s UploadSubject) CanonicalSeasonEpisode() (int, int) { + return s.SeasonInt, s.EpisodeInt } type MetadataOverrides struct { @@ -308,69 +403,91 @@ type TorrentOverrides struct { Rehash *bool } -type ExternalIDCandidates struct { - TMDB []ExternalIDCandidate - IMDB []ExternalIDCandidate - TMDBAutoSelected bool - IMDBAutoSelected bool +// TorrentSubject contains only facts and instructions required to create or +// validate a torrent artifact for one prepared source. +type TorrentSubject struct { + SourcePath string + SourceSize int64 + FileList []string + DiscType string + ClientTorrentPath string + Trackers []string + TorrentOverrides TorrentOverrides } -type ExternalIDCandidate struct { - Provider string - ID int - Title string - OriginalTitle string - Year int - Category string - MediaType string - Overview string - PosterURL string - Similarity float64 +// ClientSubject contains only source facts and caller instructions required +// for torrent-client search, linking, and injection. +type ClientSubject struct { + SourcePath string + FileList []string + DiscType string + ClientOverrides ClientOverrides } -// RuleFailureSeverity classifies whether a tracker rule result blocks work. -// The zero value and unrecognized values are treated as blocking for backward -// compatibility and fail-closed behavior. -type RuleFailureSeverity string +// RuleDisposition identifies how a failed tracker rule affects live upload. +type RuleDisposition string const ( - // RuleFailureSeverityBlocking prevents the affected tracker operation. - RuleFailureSeverityBlocking RuleFailureSeverity = "blocking" - // RuleFailureSeverityWarning reports advice without preventing the operation. - RuleFailureSeverityWarning RuleFailureSeverity = "warning" + // RuleDispositionAdvisory reports guidance without blocking live upload. + RuleDispositionAdvisory RuleDisposition = "advisory" + // RuleDispositionWaivable requires exact user authorization before live upload. + RuleDispositionWaivable RuleDisposition = "waivable" + // RuleDispositionStrict blocks live upload and cannot be authorized. + RuleDispositionStrict RuleDisposition = "strict" ) -// RuleFailure describes a failed or advisory tracker rule result. +// RuleFailure describes one stable tracker-rule result. type RuleFailure struct { - Rule string - Reason string - // Severity defaults to blocking when empty or unrecognized. - Severity RuleFailureSeverity + Rule string + Reason string + Disposition RuleDisposition } -// NormalizeRuleFailureSeverity maps the warning value to itself and all other -// values, including legacy empty values, to blocking. -func NormalizeRuleFailureSeverity(severity RuleFailureSeverity) RuleFailureSeverity { - if severity == RuleFailureSeverityWarning { - return RuleFailureSeverityWarning +// RuleAuthorization binds user consent to exact current rule keys for one tracker. +type RuleAuthorization struct { + Tracker string + Rules []string +} + +// NormalizeRuleDisposition maps legacy persisted values and fails closed for +// unknown values. Empty and legacy blocking results remain user-waivable; +// unknown non-empty values become strict. +func NormalizeRuleDisposition(disposition RuleDisposition) RuleDisposition { + switch disposition { + case RuleDispositionAdvisory, RuleDispositionWaivable, RuleDispositionStrict: + return disposition + case "warning": + return RuleDispositionAdvisory + case "", "blocking": + return RuleDispositionWaivable + default: + return RuleDispositionStrict } - return RuleFailureSeverityBlocking } // IsBlockingRuleFailure reports whether a rule result blocks tracker work. func IsBlockingRuleFailure(failure RuleFailure) bool { - return NormalizeRuleFailureSeverity(failure.Severity) == RuleFailureSeverityBlocking + return NormalizeRuleDisposition(failure.Disposition) != RuleDispositionAdvisory +} + +// IsStrictRuleFailure reports whether a rule result can never be authorized. +func IsStrictRuleFailure(failure RuleFailure) bool { + return NormalizeRuleDisposition(failure.Disposition) == RuleDispositionStrict +} + +// IsWaivableRuleFailure reports whether exact authorization may unblock a result. +func IsWaivableRuleFailure(failure RuleFailure) bool { + return NormalizeRuleDisposition(failure.Disposition) == RuleDispositionWaivable } // BlockingRuleFailures returns an independent slice containing only blocking -// results. Legacy and unrecognized severities are included. +// results. Legacy and unrecognized dispositions are included. func BlockingRuleFailures(failures []RuleFailure) []RuleFailure { return filterRuleFailures(failures, true) } -// WarningRuleFailures returns an independent slice containing only results with -// the explicit warning severity. -func WarningRuleFailures(failures []RuleFailure) []RuleFailure { +// AdvisoryRuleFailures returns an independent slice containing only advisory results. +func AdvisoryRuleFailures(failures []RuleFailure) []RuleFailure { return filterRuleFailures(failures, false) } @@ -380,11 +497,11 @@ func HasBlockingRuleFailures(failures []RuleFailure) bool { } // CountBlockingRuleFailures returns the number of rule results that block -// tracker work. Legacy and unrecognized severities are counted as blocking. +// tracker work. Legacy and unrecognized dispositions are counted as blocking. func CountBlockingRuleFailures(failures []TrackerRuleFailure) int { count := 0 for _, failure := range failures { - if NormalizeRuleFailureSeverity(failure.Severity) == RuleFailureSeverityBlocking { + if NormalizeRuleDisposition(failure.Disposition) != RuleDispositionAdvisory { count++ } } @@ -417,47 +534,6 @@ type ExternalIDOverrides struct { MALID *int } -// ExternalIDs contains canonical IDs finalized by metadata resolution. -// Downstream search, rule, preview, history, and upload code should use these -// fields instead of re-reading raw resolver fallback inputs. -type ExternalIDs struct { - // SourcePath is the content path these resolved IDs belong to. Empty means - // the record is not scoped to a specific source path. - SourcePath string - TMDBID int - IMDBID int - TVDBID int - TVmazeID int - // MALID is the canonical anime identifier used by MAL/AniList-compatible - // tracker fields after metadata resolution. - MALID int - Category string - // Source* fields record the resolver source labels that produced each - // provider ID, for example tracker, mediainfo, tmdb, or scene. - SourceTMDB string - SourceIMDB string - SourceTVDB string - SourceTVmaze string - // SourceMAL records the resolver source label for MALID. - SourceMAL string - UpdatedAt time.Time `ts_type:"string"` -} - -// ExternalMetadata stores provider-specific metadata snapshots resolved for one -// source path. Nil provider fields mean that provider has no stored snapshot. -type ExternalMetadata struct { - // SourcePath scopes the metadata to a prepared source path when set. - SourcePath string - TMDB *TMDBMetadata - IMDB *IMDBMetadata - TVDB *TVDBMetadata - TVmaze *TVmazeMetadata - // AniList stores rich anime metadata resolved from the canonical MALID. - AniList *AniListMetadata - Bluray *BlurayMetadata - UpdatedAt time.Time `ts_type:"string"` -} - // AniListMetadata is the AniList media snapshot used for MAL/AniList preview. // // Date fields keep AniList fuzzy-date precision, score fields are percentages @@ -663,8 +739,8 @@ func (m *BlurayMetadata) SelectCandidate(releaseID string, auto bool, reason str return true } -// TMDBMetadata is the shared TMDB metadata snapshot returned to CLI, Wails, and -// embedded web callers during upload preparation and review. +// TMDBMetadata is the shared TMDB metadata snapshot returned to CLI and WebUI +// callers during upload preparation and review. type TMDBMetadata struct { TMDBID int IMDBID int @@ -698,7 +774,7 @@ type TMDBMetadata struct { Keywords string // LocalizedTitles maps lowercase language codes and optional regional tags // such as "de" or "pt-BR" to TMDB translation titles. Nil values marshal as - // an empty JSON object for Wails and embedded-web callers. + // an empty JSON object for WebUI callers. LocalizedTitles map[string]string YouTube string Certification string @@ -721,12 +797,12 @@ type TMDBLocalizedData struct { Poster string } -// ExtractLocalizedPTBR returns the pt-BR localized data from the given -// metadata, or an empty value when none is available. -func ExtractLocalizedPTBR(meta PreparedMetadata) TMDBLocalizedData { - if meta.ExternalMetadata.TMDB != nil && meta.ExternalMetadata.TMDB.Localized != nil { - if v, ok := meta.ExternalMetadata.TMDB.Localized["pt-BR"]; ok { - return v +// ExtractTrackerLocalizedPTBR returns pt-BR provider data from a tracker-owned +// operation subject. +func ExtractTrackerLocalizedPTBR(subject UploadSubject) TMDBLocalizedData { + if subject.ProviderMetadata.TMDB != nil && subject.ProviderMetadata.TMDB.Localized != nil { + if value, ok := subject.ProviderMetadata.TMDB.Localized["pt-BR"]; ok { + return value } } return TMDBLocalizedData{} diff --git a/pkg/api/services_test.go b/pkg/api/services_test.go index 9c5cbd30..e05ccdd2 100644 --- a/pkg/api/services_test.go +++ b/pkg/api/services_test.go @@ -8,43 +8,46 @@ import ( "testing" ) -func TestPreparedMetadataSeasonEpisodeHelpers(t *testing.T) { - meta := PreparedMetadata{ - SeasonInt: 9, - Release: ReleaseInfo{ - Season: 1, - Episode: 2, - }, - } +func TestNewDescriptionSubjectDetachesNestedFacts(t *testing.T) { + t.Parallel() - canonicalSeason, canonicalEpisode := meta.CanonicalSeasonEpisode() - if canonicalSeason != 9 || canonicalEpisode != 0 { - t.Fatalf("canonical season/episode = %d/%d, want 9/0", canonicalSeason, canonicalEpisode) + source := UploadSubject{ + Release: ReleaseInfo{Codec: []string{"H.265"}}, + ProviderMetadata: SourceScopedMetadata{TMDB: &TMDBMetadata{ + LocalizedTitles: map[string]string{"en": "Example Release 2026"}, + }}, + SelectedBDMVPlaylists: []PlaylistInfo{{File: "00001.mpls"}}, } + projected := NewDescriptionSubject(source) + projected.Release.Codec[0] = "changed" + projected.ProviderMetadata.TMDB.LocalizedTitles["en"] = "changed" + projected.SelectedBDMVPlaylists[0].File = "changed" - fallbackSeason, fallbackEpisode := meta.SeasonEpisodeWithParsedFallback() - if fallbackSeason != 9 || fallbackEpisode != 2 { - t.Fatalf("fallback season/episode = %d/%d, want 9/2", fallbackSeason, fallbackEpisode) + if source.Release.Codec[0] != "H.265" { + t.Fatal("release facts share storage with description subject") } - if !meta.HasTVSeasonEpisodeSignal() { - t.Fatalf("expected parsed release episode to provide TV signal") + if source.ProviderMetadata.TMDB.LocalizedTitles["en"] != "Example Release 2026" { + t.Fatal("provider metadata shares storage with description subject") + } + if source.SelectedBDMVPlaylists[0].File != "00001.mpls" { + t.Fatal("playlist facts share storage with description subject") } } -func TestRuleFailureSeverityFailClosed(t *testing.T) { +func TestRuleFailureDispositionFailClosed(t *testing.T) { t.Parallel() failures := []RuleFailure{ {Rule: "legacy"}, - {Rule: "warning", Severity: RuleFailureSeverityWarning}, - {Rule: "unknown", Severity: "unexpected"}, + {Rule: "advisory", Disposition: RuleDispositionAdvisory}, + {Rule: "unknown", Disposition: "unexpected"}, } if !HasBlockingRuleFailures(failures) { - t.Fatal("expected legacy and unknown severities to block") + t.Fatal("expected legacy and unknown dispositions to block") } storedFailures := []TrackerRuleFailure{ {Rule: "legacy"}, - {Rule: "warning", Severity: RuleFailureSeverityWarning}, - {Rule: "unknown", Severity: "unexpected"}, + {Rule: "advisory", Disposition: RuleDispositionAdvisory}, + {Rule: "unknown", Disposition: "unexpected"}, } if got := CountBlockingRuleFailures(storedFailures); got != 2 { t.Fatalf("blocking count = %d, want 2", got) @@ -52,8 +55,13 @@ func TestRuleFailureSeverityFailClosed(t *testing.T) { if got := BlockingRuleFailures(failures); len(got) != 2 || got[0].Rule != "legacy" || got[1].Rule != "unknown" { t.Fatalf("unexpected blocking subset: %#v", got) } - if got := WarningRuleFailures(failures); len(got) != 1 || got[0].Rule != "warning" { - t.Fatalf("unexpected warning subset: %#v", got) + if got := AdvisoryRuleFailures(failures); len(got) != 1 || got[0].Rule != "advisory" { + t.Fatalf("unexpected advisory subset: %#v", got) + } + if NormalizeRuleDisposition("warning") != RuleDispositionAdvisory || + NormalizeRuleDisposition("blocking") != RuleDispositionWaivable || + NormalizeRuleDisposition("unexpected") != RuleDispositionStrict { + t.Fatal("legacy or unknown disposition normalization changed") } } diff --git a/pkg/api/testdata/prepared_release_display.json b/pkg/api/testdata/prepared_release_display.json new file mode 100644 index 00000000..5299fef4 --- /dev/null +++ b/pkg/api/testdata/prepared_release_display.json @@ -0,0 +1,235 @@ +{ + "ReleaseName": "Example.Release.2026.1080p-GRP", + "Providers": [ + { + "Provider": "tmdb", + "ID": 101, + "DisplayID": "101", + "URL": "https://www.themoviedb.org/movie/101", + "Provenance": "explicit", + "SummaryAvailable": true, + "Summary": { + "Title": "Example TMDB", + "OriginalTitle": "Example Original", + "Year": 2026, + "Overview": "TMDB overview", + "PosterURL": "https://img.example/tmdb-poster.jpg", + "BackdropURL": "https://img.example/tmdb-backdrop.jpg", + "Category": "movie", + "Date": "2026-01-02", + "EndDate": "2026-02-03", + "OriginalLanguage": "en", + "MediaType": "movie", + "RuntimeMinutes": 123, + "Genres": "Drama", + "Keywords": "example, release", + "TrailerURL": "https://video.example/tmdb", + "Rating": 0, + "RatingCount": 0, + "Country": "AU, NZ" + }, + "Details": { + "TMDB": { + "TMDBID": 101, + "Category": "movie", + "Title": "Example TMDB", + "OriginalTitle": "Example Original", + "Year": 2026, + "ReleaseDate": "2026-01-02", + "LastAirDate": "2026-02-03", + "OriginCountry": ["AU", "NZ"], + "OriginalLanguage": "en", + "Overview": "TMDB overview", + "Poster": "https://img.example/tmdb-poster.jpg", + "Backdrop": "https://img.example/tmdb-backdrop.jpg", + "TMDBType": "movie", + "Runtime": 123, + "Genres": "Drama", + "Keywords": "example, release", + "LocalizedTitles": {}, + "YouTube": "https://video.example/tmdb" + } + } + }, + { + "Provider": "imdb", + "ID": 123456, + "DisplayID": "tt0123456", + "URL": "https://www.imdb.com/title/tt0123456", + "Provenance": "tracker", + "SummaryAvailable": true, + "Summary": { + "Title": "Example IMDb", + "OriginalTitle": "", + "Year": 2025, + "Overview": "IMDb overview", + "PosterURL": "https://img.example/imdb-cover.jpg", + "BackdropURL": "", + "Category": "movie", + "Date": "", + "EndDate": "", + "OriginalLanguage": "en", + "MediaType": "movie", + "RuntimeMinutes": 119, + "Genres": "Mystery", + "Keywords": "", + "TrailerURL": "", + "Rating": 7.4, + "RatingCount": 4567, + "Country": "Australia" + }, + "Details": { + "IMDB": { + "IMDBID": 123456, + "Title": "Example IMDb", + "Year": 2025, + "Type": "movie", + "Plot": "IMDb overview", + "Rating": 7.4, + "RatingCount": 4567, + "RuntimeMinutes": 119, + "Genres": "Mystery", + "Country": "Australia", + "Cover": "https://img.example/imdb-cover.jpg", + "OriginalLanguage": "en" + } + } + }, + { + "Provider": "tvdb", + "ID": 303, + "DisplayID": "303", + "URL": "https://www.thetvdb.com/?id=303&tab=series", + "Provenance": "scene", + "SummaryAvailable": true, + "Summary": { + "Title": "Example TVDB", + "OriginalTitle": "", + "Year": 2024, + "Overview": "TVDB overview", + "PosterURL": "https://img.example/tvdb-poster.jpg", + "BackdropURL": "", + "Category": "movie", + "Date": "2024-03-04", + "EndDate": "", + "OriginalLanguage": "fr", + "MediaType": "series", + "RuntimeMinutes": 0, + "Genres": "Comedy", + "Keywords": "", + "TrailerURL": "", + "Rating": 0, + "RatingCount": 0, + "Country": "FR" + }, + "Details": { + "TVDB": { + "TVDBID": 303, + "Name": "Example TVDB", + "Overview": "TVDB overview", + "FirstAired": "2024-03-04", + "Year": 2024, + "Type": "series", + "OriginalCountry": "FR", + "OriginalLanguage": "fr", + "Genres": "Comedy", + "Poster": "https://img.example/tvdb-poster.jpg" + } + } + }, + { + "Provider": "tvmaze", + "ID": 404, + "DisplayID": "404", + "URL": "https://www.tvmaze.com/shows/404", + "Provenance": "arr", + "SummaryAvailable": true, + "Summary": { + "Title": "Example TVmaze", + "OriginalTitle": "", + "Year": 0, + "Overview": "TVmaze overview", + "PosterURL": "https://img.example/tvmaze-poster.jpg", + "BackdropURL": "https://img.example/tvmaze-backdrop.jpg", + "Category": "movie", + "Date": "2023-05-06", + "EndDate": "2023-07-08", + "OriginalLanguage": "es", + "MediaType": "Scripted", + "RuntimeMinutes": 47, + "Genres": "Thriller", + "Keywords": "", + "TrailerURL": "", + "Rating": 8.1, + "RatingCount": 99, + "Country": "ES" + }, + "Details": { + "TVmaze": { + "TVmazeID": 404, + "Name": "Example TVmaze", + "Premiered": "2023-05-06", + "Ended": "2023-07-08", + "Summary": "TVmaze overview", + "Type": "Scripted", + "Language": "es", + "Genres": "Thriller", + "AverageRuntime": 47, + "Rating": 8.1, + "Weight": 99, + "Country": "ES", + "Poster": "https://img.example/tvmaze-poster.jpg", + "Backdrop": "https://img.example/tvmaze-backdrop.jpg" + } + } + }, + { + "Provider": "mal", + "ID": 505, + "DisplayID": "505", + "URL": "https://myanimelist.net/anime/505", + "Provenance": "provider", + "SummaryAvailable": true, + "Summary": { + "Title": "Example Anime", + "OriginalTitle": "Example Anime Romaji", + "Year": 2022, + "Overview": "AniList overview", + "PosterURL": "https://img.example/anilist-cover.jpg", + "BackdropURL": "https://img.example/anilist-banner.jpg", + "Category": "TV", + "Date": "2022-09-10", + "EndDate": "2022-12-11", + "OriginalLanguage": "jp", + "MediaType": "TV", + "RuntimeMinutes": 24, + "Genres": "Action, Drama", + "Keywords": "", + "TrailerURL": "", + "Rating": 8.2, + "RatingCount": 6789, + "Country": "" + }, + "Details": { + "AniList": { + "AniListID": 606, + "MALID": 505, + "TitleRomaji": "Example Anime Romaji", + "TitleEnglish": "Example Anime", + "Description": "AniList overview", + "Format": "TV", + "StartDate": "2022-09-10", + "EndDate": "2022-12-11", + "SeasonYear": 2022, + "Duration": 24, + "CountryOfOrigin": "JP", + "CoverExtraLarge": "https://img.example/anilist-cover.jpg", + "BannerImage": "https://img.example/anilist-banner.jpg", + "Genres": ["Action", "Drama"], + "AverageScore": 82, + "Popularity": 6789 + } + } + } + ] +} diff --git a/pkg/api/tracker_auth.go b/pkg/api/tracker_auth.go index 34354be9..19f90f9a 100644 --- a/pkg/api/tracker_auth.go +++ b/pkg/api/tracker_auth.go @@ -5,27 +5,29 @@ package api // TrackerAuthCapability describes the auth operations the UI can offer for one tracker. type TrackerAuthCapability struct { - // TrackerID is the normalized tracker code used in bridge requests. + // TrackerID is the normalized tracker code used in WebUI requests. TrackerID string `json:"trackerID"` DisplayName string `json:"displayName"` // AuthKind is a compact capability label such as "cookies", "credential_login", or "api_key_cookies_login". - AuthKind string `json:"authKind"` - SupportsCookieFile bool `json:"supportsCookieFile"` - SupportsLogin bool `json:"supportsLogin"` - SupportsAutoLogin bool `json:"supportsAutoLogin"` - SupportsTOTP bool `json:"supportsTOTP"` - SupportsManual2FA bool `json:"supportsManual2FA"` - RequiresAPIKey bool `json:"requiresAPIKey"` - RequiresPasskey bool `json:"requiresPasskey"` - Notes []string `json:"notes"` + AuthKind string `json:"authKind"` + SupportsCookieFile bool `json:"supportsCookieFile"` + SupportsLogin bool `json:"supportsLogin"` + SupportsAutoLogin bool `json:"supportsAutoLogin"` + SupportsTOTP bool `json:"supportsTOTP"` + SupportsManual2FA bool `json:"supportsManual2FA"` + // SupportsRemoteValidation reports whether Check Auth can execute a tracker-owned remote resolver. + SupportsRemoteValidation bool `json:"supportsRemoteValidation"` + RequiresAPIKey bool `json:"requiresAPIKey"` + RequiresPasskey bool `json:"requiresPasskey"` + Notes []string `json:"notes"` } // TrackerAuthStatus reports the current local tracker auth state after a // status, import, login, validation, 2FA, or delete action. // -// Wails, embedded web, and frontend consumers must expose the same JSON fields +// WebUI routes and TypeScript consumers must expose the same JSON fields // so status summaries, remediation messages, and Promise-visible errors stay in -// parity across GUI entrypoints. +// parity across browser entrypoints. type TrackerAuthStatus struct { TrackerID string `json:"trackerID"` DisplayName string `json:"displayName"` diff --git a/pkg/api/tracker_auth_test.go b/pkg/api/tracker_auth_test.go index a366d17c..37548f53 100644 --- a/pkg/api/tracker_auth_test.go +++ b/pkg/api/tracker_auth_test.go @@ -5,7 +5,6 @@ package api import ( "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -60,30 +59,37 @@ func TestTrackerAuthStatusConsumerContractsIncludeAPIFields(t *testing.T) { t.Parallel() fields := trackerAuthStatusJSONFields(t) - frontendTypes := readRepoFile(t, "gui", "frontend", "src", "types.ts") + frontendTypes := readRepoFile(t, "webui", "src", "types.ts") assertContractBlockFields(t, frontendTypes, "export type TrackerAuthStatus = {", "};", fields) +} - wailsModels, ok := readOptionalRepoFile(t, "gui", "frontend", "wailsjs", "go", "models.ts") - if !ok { - t.Log("skipping generated Wails model contract check; gui/frontend/wailsjs/go/models.ts is ignored and absent in clean checkouts") - return - } - assertContractBlockFields(t, wailsModels, "export class TrackerAuthStatus {", "static createFrom", fields) +func TestTrackerAuthCapabilityConsumerContractsIncludeAPIFields(t *testing.T) { + t.Parallel() + + fields := jsonFieldNames[TrackerAuthCapability](t) + frontendTypes := readRepoFile(t, "webui", "src", "types.ts") + + assertContractBlockFields(t, frontendTypes, "export type TrackerAuthCapability = {", "};", fields) } // trackerAuthStatusJSONFields returns the serialized field names that every // frontend-facing auth status consumer must keep in its local contract. func trackerAuthStatusJSONFields(t *testing.T) []string { t.Helper() + return jsonFieldNames[TrackerAuthStatus](t) +} + +func jsonFieldNames[T any](t *testing.T) []string { + t.Helper() - statusType := reflect.TypeFor[TrackerAuthStatus]() - fields := make([]string, 0, statusType.NumField()) - for field := range statusType.Fields() { + contractType := reflect.TypeFor[T]() + fields := make([]string, 0, contractType.NumField()) + for field := range contractType.Fields() { tag := field.Tag.Get("json") name, _, _ := strings.Cut(tag, ",") if name == "" || name == "-" { - t.Fatalf("TrackerAuthStatus field %s missing JSON contract tag", field.Name) + t.Fatalf("%s field %s missing JSON contract tag", contractType.Name(), field.Name) } fields = append(fields, name) } @@ -123,20 +129,6 @@ func readRepoFile(t *testing.T, parts ...string) string { return string(content) } -func readOptionalRepoFile(t *testing.T, parts ...string) (string, bool) { - t.Helper() - - content, err := readRepoFileContent(parts...) - if err == nil { - return string(content), true - } - if errors.Is(err, os.ErrNotExist) { - return "", false - } - t.Fatalf("read optional repo contract file %v: %v", parts, err) - return "", false -} - func readRepoFileContent(parts ...string) ([]byte, error) { pathParts := append([]string{"..", ".."}, parts...) content, err := os.ReadFile(filepath.Join(pathParts...)) diff --git a/pkg/api/tracker_catalog.go b/pkg/api/tracker_catalog.go new file mode 100644 index 00000000..a1fc0c96 --- /dev/null +++ b/pkg/api/tracker_catalog.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +// TrackerCatalogField describes one ordered tracker setting without exposing its current value. +type TrackerCatalogField struct { + Key string `json:"key"` + YAMLKey string `json:"yamlKey"` + Default any `json:"default"` + Activation bool `json:"activation"` +} + +// TrackerCatalogEntry combines tracker implementation identity with its config schema. +type TrackerCatalogEntry struct { + Name string `json:"name"` + Family string `json:"family"` + BaseURL string `json:"baseURL"` + UploadContentMode string `json:"uploadContentMode"` + Fields []TrackerCatalogField `json:"fields"` + Configured bool `json:"configured"` +} + +// TrackerCatalog is the complete settings manifest for supported trackers and +// the names of preserved inert entries that no implementation owns. +type TrackerCatalog struct { + Entries []TrackerCatalogEntry `json:"entries"` + Unsupported []string `json:"unsupported"` +} diff --git a/pkg/api/tracker_eligibility.go b/pkg/api/tracker_eligibility.go new file mode 100644 index 00000000..8acf8a2c --- /dev/null +++ b/pkg/api/tracker_eligibility.go @@ -0,0 +1,83 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package api + +// TrackerEligibilityReasonCode identifies one canonical tracker blocker. +type TrackerEligibilityReasonCode string + +const ( + TrackerEligibilityDuplicate TrackerEligibilityReasonCode = "duplicate" + TrackerEligibilityBlockingRule TrackerEligibilityReasonCode = "blocking_rule" + TrackerEligibilityPolicy TrackerEligibilityReasonCode = "policy" + TrackerEligibilityAuthRequired TrackerEligibilityReasonCode = "auth_required" + TrackerEligibilityAssessmentSkipped TrackerEligibilityReasonCode = "assessment_skipped" + TrackerEligibilityAssessmentFailed TrackerEligibilityReasonCode = "assessment_failed" + TrackerEligibilityBannedGroup TrackerEligibilityReasonCode = "banned_group" + TrackerEligibilityScreenshotPreparationFailed TrackerEligibilityReasonCode = "screenshot_preparation_failed" + TrackerEligibilityDescriptionPreparationFailed TrackerEligibilityReasonCode = "description_preparation_failed" +) + +// TrackerEligibilityReason is safe presentation data for one blocker. +type TrackerEligibilityReason struct { + Code TrackerEligibilityReasonCode + Message string +} + +// TrackerContentFailure is sanitized tracker-scoped evidence that a required +// shared upload-content object failed before its adapter could run. +type TrackerContentFailure struct { + Tracker string `json:"tracker"` + Code TrackerEligibilityReasonCode `json:"code"` + Message string `json:"message"` +} + +// TrackerReviewChoices records explicit review authorizations for one tracker. +type TrackerReviewChoices struct { + IgnoreDuplicate bool + AuthorizedRuleFailures []string +} + +// TrackerEligibilityAssessment is Core input gathered from current exact- +// generation policy, duplicate, auth, and dry-run assessment. +type TrackerEligibilityAssessment struct { + Tracker string + Duplicate DupeCheckResult + RuleFailures []RuleFailure + PolicyBlocks []TrackerBlockReason + AuthRequired bool + Banned bool + BannedReason string + ContentFailure *TrackerContentFailure + Choices TrackerReviewChoices +} + +// TrackerEligibilityInput requests ordered eligibility for an exact release. +type TrackerEligibilityInput struct { + Release ReleaseRef + SelectedTrackers []string + Assessments []TrackerEligibilityAssessment +} + +// TrackerEligibilityState is one selected tracker's canonical decision. +type TrackerEligibilityState struct { + Tracker string + Eligible bool + Reasons []TrackerEligibilityReason + RuleDecisions []RuleDecision +} + +// RuleDecision retains one failed rule and whether exact user authorization applies. +type RuleDecision struct { + Rule string + Reason string + Disposition RuleDisposition + Authorized bool +} + +// TrackerEligibility is the ordered canonical decision for one exact release. +type TrackerEligibility struct { + Release ReleaseRef + Trackers []TrackerEligibilityState + EligibleTrackers []string +} diff --git a/pkg/api/upload_progress.go b/pkg/api/upload_progress.go index 9e6ca06e..591493d9 100644 --- a/pkg/api/upload_progress.go +++ b/pkg/api/upload_progress.go @@ -3,25 +3,63 @@ package api -import "context" +import ( + "context" + "errors" +) +// UploadProgressUpdate describes one source- and tracker-scoped upload progress event. type UploadProgressUpdate struct { - SourcePath string `json:"sourcePath"` - Tracker string `json:"tracker"` - Task string `json:"task"` - Status string `json:"status"` - Message string `json:"message"` - CompletedPieces int `json:"completedPieces"` - TotalPieces int `json:"totalPieces"` - Percent int `json:"percent"` - HashRateMiB float64 `json:"hashRateMiB"` - Timestamp string `json:"timestamp"` + // SourcePath is the host filesystem release path. + SourcePath string `json:"sourcePath"` + // Tracker identifies the tracker when the task is tracker-specific. + Tracker string `json:"tracker"` + // Task is the stable progress operation identifier. + Task string `json:"task"` + // Status is the stable lifecycle state for Task. + Status string `json:"status"` + // Message contains sanitized user-facing progress detail. + Message string `json:"message"` + // CompletedPieces is the number of torrent pieces hashed so far. + CompletedPieces int `json:"completedPieces"` + // TotalPieces is the total number of torrent pieces to hash. + TotalPieces int `json:"totalPieces"` + // Percent is integer completion percentage from 0 through 100. + Percent int `json:"percent"` + // HashRateMiB is the current hashing throughput in MiB per second. + HashRateMiB float64 `json:"hashRateMiB"` + // Timestamp is the event time serialized by the producer. + Timestamp string `json:"timestamp"` } +// UploadProgressReporter receives synchronous progress notifications from an upload context. type UploadProgressReporter func(update UploadProgressUpdate) +// TrackerLocalUploadError distinguishes attributable tracker failures from operation-wide errors. +type TrackerLocalUploadError interface { + error + // TrackerLocalUploadFailures returns tracker names whose attributable work failed. + TrackerLocalUploadFailures() []string +} + +// IsTrackerLocalUploadError reports whether err contains tracker-local failure classification. +func IsTrackerLocalUploadError(err error) bool { + var local TrackerLocalUploadError + return errors.As(err, &local) +} + +// TrackerLocalUploadFailureNames returns a defensive tracker list from a classified error. +func TrackerLocalUploadFailureNames(err error) []string { + var local TrackerLocalUploadError + if !errors.As(err, &local) { + return nil + } + return append([]string(nil), local.TrackerLocalUploadFailures()...) +} + type uploadProgressReporterKey struct{} +// WithUploadProgressReporter returns a context carrying reporter; nil reporters leave the context unchanged. func WithUploadProgressReporter(ctx context.Context, reporter UploadProgressReporter) context.Context { if ctx == nil { ctx = context.Background() @@ -32,6 +70,7 @@ func WithUploadProgressReporter(ctx context.Context, reporter UploadProgressRepo return context.WithValue(ctx, uploadProgressReporterKey{}, reporter) } +// EmitUploadProgress synchronously invokes the reporter attached to ctx, when present. func EmitUploadProgress(ctx context.Context, update UploadProgressUpdate) { if ctx == nil { return diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f3b5683c..5dfbbc9c 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -2,44 +2,21 @@ $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root -$wailsCli = "github.com/wailsapp/wails/v2/cmd/wails@v2.12.0" - -Write-Host "Building frontend..." -Push-Location "gui/frontend" -pnpm install +Write-Host "Building WebUI..." +Push-Location "webui" +pnpm install --frozen-lockfile pnpm run build Pop-Location -Write-Host "Syncing embedded GUI assets..." -$assetsPath = Join-Path $root "internal/guiapp/assets" -New-Item -ItemType Directory -Force -Path $assetsPath | Out-Null -Get-ChildItem -Path $assetsPath -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -ne ".keep" } | - Remove-Item -Recurse -Force -Copy-Item "gui/frontend/dist/*" $assetsPath -Recurse -Force +Write-Host "Syncing embedded WebUI assets..." +& (Join-Path $PSScriptRoot "sync-webui-assets.ps1") -Write-Host "Building CLI binary..." +Write-Host "Building upbrr binary..." $distPath = Join-Path $root "dist" if (-not (Test-Path $distPath)) { New-Item -ItemType Directory -Force -Path $distPath | Out-Null } $cliOut = Join-Path $distPath "upbrr.exe" go build -o $cliOut ./cmd/upbrr -$sourceBin = Join-Path $root "bin" -if (Test-Path $sourceBin) { - Write-Host "Syncing optional bundled tools to CLI output..." - $distBin = Join-Path $distPath "bin" - if (Test-Path $distBin) { - Remove-Item $distBin -Recurse -Force - } - Copy-Item $sourceBin $distBin -Recurse -Force -} else { - Write-Host "Skipping optional bundled tools: no top-level bin directory found." -} - -Write-Host "Building GUI binary (portable exe)..." -Push-Location "gui" -go run $wailsCli build -platform windows/amd64 -Pop-Location -Write-Host "Done. Binaries: dist/upbrr.exe (CLI) and gui/build/bin/upbrr-gui.exe (GUI)" +Write-Host "Done. Binary: dist/upbrr.exe" diff --git a/scripts/build.sh b/scripts/build.sh index 3c0722a0..c57391ed 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -3,35 +3,18 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" -wails_cli="github.com/wailsapp/wails/v2/cmd/wails@v2.12.0" - -echo "Building frontend..." +echo "Building WebUI..." ( - cd gui/frontend - pnpm install + cd webui + pnpm install --frozen-lockfile pnpm run build ) -echo "Syncing embedded GUI assets..." -mkdir -p internal/guiapp/assets -find internal/guiapp/assets -mindepth 1 ! -name '.keep' -exec rm -rf {} + -cp -R gui/frontend/dist/* internal/guiapp/assets/ +echo "Syncing embedded WebUI assets..." +sh scripts/sync-webui-assets.sh -echo "Building CLI binary..." +echo "Building upbrr binary..." mkdir -p dist GOOS="" GOARCH="" go build -o dist/upbrr ./cmd/upbrr -if [[ -d bin ]]; then - echo "Syncing optional bundled tools to CLI output..." - rm -rf dist/bin - cp -R bin dist/bin -else - echo "Skipping optional bundled tools: no top-level bin directory found." -fi - -echo "Building GUI binary..." -( - cd gui - go run "$wails_cli" build -) -echo "Done. Binaries: dist/upbrr (CLI) and gui/build/bin/upbrr-gui (GUI)" +echo "Done. Binary: dist/upbrr" diff --git a/scripts/sync-frontend-assets.ps1 b/scripts/sync-frontend-assets.ps1 deleted file mode 100644 index da01fbfd..00000000 --- a/scripts/sync-frontend-assets.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -$assetsPath = Join-Path $root "internal/guiapp/assets" - -New-Item -ItemType Directory -Force -Path $assetsPath | Out-Null -Get-ChildItem -Path $assetsPath -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -ne ".keep" } | - Remove-Item -Recurse -Force -Copy-Item (Join-Path $root "gui/frontend/dist/*") $assetsPath -Recurse -Force diff --git a/scripts/sync-frontend-assets.sh b/scripts/sync-frontend-assets.sh deleted file mode 100755 index 2776adaf..00000000 --- a/scripts/sync-frontend-assets.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env sh -set -eu - -root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -assets_path="$root/internal/guiapp/assets" - -mkdir -p "$assets_path" -find "$assets_path" -mindepth 1 ! -name ".keep" -exec rm -rf {} + -cp -R "$root/gui/frontend/dist/." "$assets_path/" diff --git a/scripts/sync-webui-assets.ps1 b/scripts/sync-webui-assets.ps1 new file mode 100644 index 00000000..04128317 --- /dev/null +++ b/scripts/sync-webui-assets.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +$root = (Resolve-Path (Split-Path -Parent $PSScriptRoot)).Path +$assetsPath = [System.IO.Path]::GetFullPath((Join-Path $root "internal/webserver/assets")) +if (-not $assetsPath.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to sync assets outside repository: $assetsPath" +} + +New-Item -ItemType Directory -Force -Path $assetsPath | Out-Null +Get-ChildItem -Path $assetsPath -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne ".keep" } | + Remove-Item -Recurse -Force +Copy-Item (Join-Path $root "webui/dist/*") $assetsPath -Recurse -Force diff --git a/scripts/sync-webui-assets.sh b/scripts/sync-webui-assets.sh new file mode 100644 index 00000000..c39da04b --- /dev/null +++ b/scripts/sync-webui-assets.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +assets_path="$root/internal/webserver/assets" + +mkdir -p "$assets_path" +find "$assets_path" -mindepth 1 ! -name ".keep" -exec rm -rf {} + +cp -R "$root/webui/dist/." "$assets_path/" diff --git a/gui/frontend/.prettierignore b/webui/.prettierignore similarity index 86% rename from gui/frontend/.prettierignore rename to webui/.prettierignore index 5acacd22..dd87ea92 100644 --- a/gui/frontend/.prettierignore +++ b/webui/.prettierignore @@ -6,9 +6,6 @@ build/ # Dependencies node_modules/ -# Generated -wailsjs/ - # Lockfiles pnpm-lock.yaml package-lock.json @@ -18,6 +15,8 @@ yarn.lock coverage/ .cache/ .eslintcache +playwright-report/ +test-results/ # Binary / minified *.min.js diff --git a/gui/frontend/.prettierrc.json b/webui/.prettierrc.json similarity index 100% rename from gui/frontend/.prettierrc.json rename to webui/.prettierrc.json diff --git a/webui/AGENTS.md b/webui/AGENTS.md new file mode 100644 index 00000000..b254435a --- /dev/null +++ b/webui/AGENTS.md @@ -0,0 +1,81 @@ +# Frontend Guidelines + +Scoped rules for `webui`. Root repo rules still apply. + +## Source Of Truth + +- Scripts and dependencies: `package.json`, `pnpm-lock.yaml`. +- TypeScript config: `tsconfig*.json`, `vite.config.ts`, `vitest.config.ts`. +- Lint/format behavior: ESLint, Prettier, Stylelint config files and Lefthook. +- API/runtime contracts: `pkg/api`, `internal/webserver`, typed clients under `src/api`, release ownership under `src/releaseSession`, and shared Job coordination under `src/jobRegistry`. + +## Commands + +```bash +pnpm --dir webui run typecheck +pnpm --dir webui run test:unit +pnpm --dir webui run lint +pnpm --dir webui run lint:style +pnpm --dir webui run format:check +pnpm --dir webui run build +``` + +## Check Selection + +- TS/TSX changes: `pnpm --dir webui run lint`, `lint:dead`, `typecheck`, `test:unit`, and `format:check`. +- CSS changes: `pnpm --dir webui run lint:style`; also run `format:check`. +- Browser client/API changes: frontend `test:unit` + `typecheck`, plus backend/API tests from `internal/AGENTS.md` and `pkg/api/AGENTS.md`. +- Bundle/import/env changes: `pnpm --dir webui run build`. +- Visual/embedded behavior changes: rebuild/sync embedded assets and inspect `http://localhost:7480`; avoid Vite `5173` for parity. + +`make test-frontend` runs lint, dead-code, typecheck, unit, and format checks, but not Stylelint. Run Stylelint explicitly for CSS. + +## React / TypeScript + +- Keep TypeScript, ESLint, Stylelint, dead-code clean. Do not weaken rules. +- `useEffect` only for external sync. Avoid derived state in effects; render or `useMemo` instead. +- User-driven logic belongs in handlers. Fetch effects need cleanup/abort guards. +- Preserve CLI and WebUI behavior when changing shared request shapes, upload options, or prepared metadata. +- Match existing component state patterns before adding new abstraction. + +## Release Session / Job Ownership + +- Release workflow pages consume `useReleaseSession` facets; they do not import production API clients to coordinate release operations directly. +- `src/releaseSession` owns active release state, operation intents, and preparation/image-upload progress subscriptions. Views render facet state instead of subscribing independently. +- `src/jobRegistry` owns `jobs:update` transport and duplicate-check/tracker-upload Job coordination. Active release Job access remains behind `useReleaseSession`. +- Facets expose declarative state and intent methods, not React setters, dispatch functions, or refs. +- Use structured failure codes/metadata for recovery. Do not infer recovery from error-message text. +- Consume backend-provided disc resource paths; do not derive BDMV paths from preparation source strings in the frontend. + +## Frontend Output / Logging + +- Follow root log-level guidance for browser-visible diagnostics and WebUI event logging. +- Do not expose credentials, tokens, API keys, passkeys, cookies, 2FA codes, challenge IDs, or secret payloads in console output, UI errors, toasts, test failure text, or debug panels. +- Avoid permanent `console.*` diagnostics. If a diagnostic is intentionally kept, make it dev-scoped, concise, and redacted. +- User-facing errors should be stable outcomes or next steps; detailed troubleshooting context belongs in developer diagnostics. + +## Styling + +- Prefer Tailwind utilities for touched local layout/spacing. +- Keep CSS for shared/theme/cross-cutting selectors or JSX readability. +- Do not make repo-wide format/style sweeps unless explicitly requested. +- Text must fit containers across desktop/mobile; do not rely on viewport-width font scaling. + +## Embedded Web Checks + +- For embedded visual/runtime checks, rebuild frontend, sync embedded assets, rebuild CLI, then serve the embedded app: + +```bash +pnpm --dir webui run build +pwsh -NoProfile -File .\scripts\sync-webui-assets.ps1 +go build -o .\dist\upbrr.exe .\cmd\upbrr +.\dist\upbrr.exe serve --dev-no-auth +``` + +- Use `http://localhost:7480`. +- Avoid Vite `5173` for embedded parity checks. +- Stop local servers after inspection. + +## E2E + +For Playwright E2E work, read `e2e/AGENTS.md` first. E2E tests must use the embedded web UI, local fake services, isolated temp config/DB, and no real credentials. diff --git a/webui/CLAUDE.md b/webui/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/webui/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/webui/e2e/AGENTS.md b/webui/e2e/AGENTS.md new file mode 100644 index 00000000..6e93ca8c --- /dev/null +++ b/webui/e2e/AGENTS.md @@ -0,0 +1,63 @@ +# E2E Guidelines + +Scoped rules for Playwright E2E under `webui/e2e`. Root and frontend rules still apply. + +## Commands + +```bash +make e2e +make e2e-web +make e2e-cli +pnpm --dir webui run test:e2e:web +pnpm --dir webui run test:e2e:full +``` + +`make e2e` is the preferred full local command. It installs frontend deps, builds the frontend, syncs embedded assets, builds `dist/upbrr-e2e.exe` with the `e2e` tag, and runs all Playwright projects. + +If Playwright browsers are missing: + +```bash +pnpm --dir webui exec playwright install chromium +``` + +To open the HTML report from repo root: + +```bash +pnpm --dir webui exec playwright show-report +``` + +## Projects + +- `web-smoke`: embedded server at `http://localhost:7480`, `--dev-no-auth`; nav/settings/invalid-input smoke coverage. +- `web-base-path-smoke`: embedded server mounted below a configured base path; asset, navigation, API, and event-stream routing coverage. +- `web-full-upload`: metadata, screenshot/image upload, tracker dry-run, tracker upload, and history through embedded web UI. +- `cli-full-upload`: full CLI upload path against local fakes and temp config/DB. + +## Harness Rules + +- Web UI E2E uses the embedded app as source of truth, not Vite. +- Use isolated temp workspace per test: config YAML, SQLite DB, media/torrent/screenshot fixtures. +- Use local fake tracker/image-host/torrent/metadata services only. +- No real tracker, image host, torrent client, TMDB, or credentials in E2E. +- Service seams must be test-only or config/test fixture driven; production defaults stay unchanged. +- Process manager must clean up `dist/upbrr-e2e.exe serve --config \config.yaml --dev-no-auth`. + +## Generated Artifacts + +Ignored local outputs: + +- `webui/playwright-report/` +- `webui/test-results/` + +Do not commit Playwright traces, videos, screenshots, reports, temp DBs, or `dist/upbrr-e2e.exe`. + +## CI + +Manual workflow only: + +- `.github/workflows/e2e.yml` +- `workflow_dispatch` +- Builds frontend + embedded assets + CLI. +- Installs Playwright Chromium. +- Runs `make e2e`. +- Uploads report/traces on failure. diff --git a/webui/e2e/CLAUDE.md b/webui/e2e/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/webui/e2e/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/gui/frontend/e2e/cli-full-upload.spec.ts b/webui/e2e/cli-full-upload.spec.ts similarity index 96% rename from gui/frontend/e2e/cli-full-upload.spec.ts rename to webui/e2e/cli-full-upload.spec.ts index 350c0201..5a1d1d43 100644 --- a/gui/frontend/e2e/cli-full-upload.spec.ts +++ b/webui/e2e/cli-full-upload.spec.ts @@ -23,6 +23,7 @@ test("CLI full upload uses local fake services and records an upload", async () expect(result.code, result.output).toBe(0); expect(result.output).toMatch(/uploaded|complete|Upload/i); expect(workspace.fake.counters.trackerUploads).toBe(1); + expect(workspace.fake.counters.clientSearches).toBe(1); } finally { await workspace.cleanup(); } diff --git a/gui/frontend/e2e/helpers/e2eHarness.ts b/webui/e2e/helpers/e2eHarness.ts similarity index 83% rename from gui/frontend/e2e/helpers/e2eHarness.ts rename to webui/e2e/helpers/e2eHarness.ts index c742b399..15e4cd0b 100644 --- a/gui/frontend/e2e/helpers/e2eHarness.ts +++ b/webui/e2e/helpers/e2eHarness.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { expect, type Page } from "@playwright/test"; const here = path.dirname(fileURLToPath(import.meta.url)); -export const repoRoot = path.resolve(here, "../../../.."); +export const repoRoot = path.resolve(here, "../../.."); export const e2eBinary = path.join( repoRoot, "dist", @@ -28,6 +28,8 @@ const startAppBindAttempts = 5; type FakeCounters = { trackerUploads: number; imageUploads: number; + clientSearches: number; + clientInjections: number; }; type FakeServer = { @@ -75,6 +77,7 @@ export async function createE2EWorkspace(): Promise { UPBRR_E2E_FAKE_SERVICES: "1", UPBRR_E2E_TRACKER_URL: fake.url, UPBRR_E2E_IMAGE_URL: fake.url, + UPBRR_E2E_CLIENT_URL: fake.url, UPBRR_E2E_SCREENSHOT_PATH: screenshotPath, }; return { @@ -92,6 +95,34 @@ export async function createE2EWorkspace(): Promise { }; } +/** Adds a minimal parseable BDMV source without invoking any external disc tool. */ +export async function createBluraySourceFixture(workspace: E2EWorkspace): Promise { + const discRoot = path.join(workspace.root, "media", "Example Disc"); + const bdmvRoot = path.join(discRoot, "BDMV"); + const playlistDir = path.join(bdmvRoot, "PLAYLIST"); + const streamDir = path.join(bdmvRoot, "STREAM"); + await mkdir(playlistDir, { recursive: true }); + await mkdir(streamDir, { recursive: true }); + + const playlist = Buffer.alloc(64); + playlist.write("MPLS", 0, "ascii"); + playlist.write("0200", 4, "ascii"); + playlist.writeUInt32BE(32, 8); + playlist.writeUInt32BE(64, 12); + playlist.writeUInt32BE(0, 16); + playlist.writeUInt32BE(30, 32); + playlist.writeUInt16BE(1, 38); + playlist.writeUInt16BE(0, 40); + playlist.writeUInt16BE(20, 42); + playlist.write("00001", 44, "ascii"); + playlist.write("M2TS", 49, "ascii"); + playlist.writeUInt32BE(0, 56); + playlist.writeUInt32BE(45_000 * 120, 60); + await writeFile(path.join(playlistDir, "00001.mpls"), playlist); + await writeFile(path.join(streamDir, "00001.m2ts"), "synthetic Blu-ray stream\n"); + return discRoot; +} + /** * Starts the embedded web server for a workspace and waits for auth status at * the configured base path before returning its browser URL. Startup retries @@ -209,7 +240,7 @@ image_hosting: img_host_1: "imgbb" imgbb_api: "e2e" metadata: - skip_auto_torrent: true + skip_auto_torrent: false keep_images: true screenshot_handling: screens: 1 @@ -223,19 +254,49 @@ logging: trackers: default_trackers: ["BTN"] preferred_tracker: "BTN" + AITHER: + api_key: "e2e" + image_host: "imgbb" + ANT: + api_key: "e2e" + image_host: "imgbb" + FF: + username: "e2e" + password: "e2e" + HDS: + announce_url: "http://tracker.invalid/announce" + image_host: "imgbb" BTN: api_key: "e2e" username: "e2e" password: "e2e" url: "http://127.0.0.1" image_host: "imgbb" + OLD: + api_key: "preserved" + url: "http://retired.invalid" torrent_clients: {} `; } async function startFakeServer(): Promise { - const counters: FakeCounters = { trackerUploads: 0, imageUploads: 0 }; + const counters: FakeCounters = { + trackerUploads: 0, + imageUploads: 0, + clientSearches: 0, + clientInjections: 0, + }; const server = createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/client-search") { + counters.clientSearches++; + writeJSON(res, 200, { ok: true }); + return; + } + if (req.method === "POST" && req.url === "/client-inject") { + counters.clientInjections++; + writeJSON(res, 200, { ok: true }); + return; + } if (req.method === "POST" && req.url === "/upload") { const body = await readBody(req); if (body.includes(Buffer.from('name="tracker"'))) { diff --git a/gui/frontend/e2e/web-base-path-smoke.spec.ts b/webui/e2e/web-base-path-smoke.spec.ts similarity index 81% rename from gui/frontend/e2e/web-base-path-smoke.spec.ts rename to webui/e2e/web-base-path-smoke.spec.ts index b795a85f..e46d97dc 100644 --- a/gui/frontend/e2e/web-base-path-smoke.spec.ts +++ b/webui/e2e/web-base-path-smoke.spec.ts @@ -31,10 +31,15 @@ test("embedded web serves UI, API, assets, manifest, and events under a base pat await expect(page.getByRole("heading", { name: "Build Release Name" })).toBeVisible(); await eventRequest; - const defaultConfig = await page.evaluate(() => - (globalThis as any).go.guiapp.App.GetDefaultConfig(), - ); - expect(defaultConfig).toBeTruthy(); + const apiAuthStatus = await request.get(`${origin}/upbrr/api/auth/status`); + expect(apiAuthStatus.ok()).toBe(true); + const { csrfToken } = (await apiAuthStatus.json()) as { csrfToken: string }; + const defaultConfig = await request.post(`${origin}/upbrr/api/app/GetDefaultConfig`, { + data: {}, + headers: { Origin: origin, "X-CSRF-Token": csrfToken }, + }); + expect(defaultConfig.ok()).toBe(true); + expect(await defaultConfig.json()).toBeTruthy(); const manifest = await request.get(`${origin}/upbrr/site.webmanifest`); expect(manifest.ok()).toBe(true); diff --git a/webui/e2e/web-full-upload.spec.ts b/webui/e2e/web-full-upload.spec.ts new file mode 100644 index 00000000..4daa6ce6 --- /dev/null +++ b/webui/e2e/web-full-upload.spec.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { expect, test, type Locator } from "@playwright/test"; +import { + createBluraySourceFixture, + createE2EWorkspace, + fetchMetadata, + startApp, + type AppServer, +} from "./helpers/e2eHarness"; + +const expectEnabledState = async (locator: Locator, enabled: boolean) => { + if (enabled) { + await expect(locator).toBeEnabled(); + return; + } + await expect(locator).toBeDisabled(); +}; + +for (const scenario of [ + { + name: "none trackers bypass shared content pages", + trackers: ["BTN"], + screenshots: false, + descriptions: false, + upload: true, + }, + { + name: "screenshot trackers expose image pages without requiring descriptions", + trackers: ["ANT"], + screenshots: true, + descriptions: false, + upload: true, + }, + { + name: "mixed trackers expose the strictest shared content workflow", + trackers: ["BTN", "ANT", "AITHER"], + screenshots: true, + descriptions: true, + upload: false, + }, +] as const) { + test(`embedded web ${scenario.name}`, async ({ page }) => { + const workspace = await createE2EWorkspace(); + let app: AppServer | undefined; + try { + app = await startApp(workspace); + await fetchMetadata(page, app.url, workspace.sourcePath); + await page.getByRole("button", { name: "Dupe Check" }).click(); + for (const tracker of scenario.trackers) { + await page.getByRole("checkbox", { name: tracker }).check(); + } + await page.getByRole("button", { name: "Run dupe check" }).click(); + await expect(page.getByRole("button", { name: "Run dupe check" })).toBeEnabled(); + + await expectEnabledState( + page.getByRole("button", { name: "Screenshots" }), + scenario.screenshots, + ); + await expectEnabledState( + page.getByRole("button", { name: "Upload Images" }), + scenario.screenshots, + ); + await expectEnabledState( + page.getByRole("button", { name: "Descriptions" }), + scenario.descriptions, + ); + await expectEnabledState( + page.getByRole("button", { name: "Upload", exact: true }), + scenario.upload, + ); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + } finally { + await app?.stop(); + await workspace.cleanup(); + } + }); +} + +test("embedded web runs image upload, tracker dry run, tracker upload, and history", async ({ + page, +}) => { + const workspace = await createE2EWorkspace(); + let app: AppServer | undefined; + try { + app = await startApp(workspace); + await fetchMetadata(page, app.url, workspace.sourcePath); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + await page.getByRole("button", { name: "Dupe Check" }).click(); + await expect( + page.getByText("Select at least one tracker to run duplicate checking."), + ).toBeVisible(); + await page.getByRole("checkbox", { name: "FF" }).check(); + await page.getByRole("button", { name: "Run dupe check" }).click(); + await expect(page.getByText("FF").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Run dupe check" })).toBeEnabled(); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + + await page.getByRole("button", { name: "Screenshots" }).click(); + const saveFinalResponse = page.waitForResponse((response) => + response.url().includes("/api/app/SaveFinalScreenshotSelections"), + ); + await page.getByRole("button", { name: "Generate screenshots" }).click(); + await expect((await saveFinalResponse).ok()).toBe(true); + await expect(page.getByRole("button", { name: "Screenshot 1" })).toBeVisible(); + await page.getByRole("button", { name: "Upload Images" }).click(); + await expect(page.getByText("1 found")).toBeVisible(); + await page.getByRole("combobox", { name: "Image host" }).selectOption("imgbb"); + await page.getByRole("button", { name: "Upload selected (1)" }).click(); + await expect.poll(() => workspace.fake.counters.imageUploads).toBe(1); + await expect(page.getByRole("link", { name: /\/image\/1$/ }).first()).toHaveAttribute( + "href", + /\/image\/1$/, + ); + + await page.getByRole("button", { name: "Descriptions" }).click(); + await page.getByRole("button", { name: "Refresh descriptions" }).click(); + await page.getByRole("button", { name: "Expand" }).click(); + await expect(page.locator("textarea").first()).toHaveValue(/E2E description fixture\./); + + await page.getByRole("button", { name: "Upload", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Review & Upload" })).toBeVisible(); + await page.getByLabel("Log level").selectOption("debug"); + await page.getByRole("button", { name: "Run dry run" }).click(); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + await expect.poll(() => workspace.fake.counters.clientInjections).toBe(1); + expect(workspace.fake.counters.trackerUploads).toBe(0); + const reviewButton = page.getByRole("button", { name: "Review upload" }); + await expect(reviewButton).toBeEnabled(); + await expect(page.getByRole("heading", { name: "FF" })).toBeVisible(); + await expect(page.getByText("E2E.Movie.2026.1080p.WEB-DL").first()).toBeVisible(); + + await reviewButton.click(); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + const startButton = page.getByRole("button", { name: "Start upload" }); + await expect(startButton).toBeEnabled(); + await startButton.click(); + await expect.poll(() => workspace.fake.counters.trackerUploads).toBe(1); + await expect(page.getByText(/Uploaded 1/)).toBeVisible(); + + await page.getByRole("button", { name: "History" }).click(); + await expect(page.getByText("E2E.Movie.2026.1080p.WEB-DL").first()).toBeVisible(); + await expect(page.getByText("FF").first()).toBeVisible(); + } finally { + await app?.stop(); + await workspace.cleanup(); + } +}); + +test("embedded web keeps strict rules and dupes diagnostic during dry run", async ({ page }) => { + const workspace = await createE2EWorkspace(); + workspace.env.UPBRR_E2E_RESOLUTION = "576p"; + workspace.env.UPBRR_E2E_DUPLICATE_TRACKERS = "HDS"; + let app: AppServer | undefined; + try { + app = await startApp(workspace); + await fetchMetadata(page, app.url, workspace.sourcePath); + await page.getByRole("button", { name: "Dupe Check" }).click(); + await page.getByRole("checkbox", { name: "HDS" }).check(); + await page.getByRole("button", { name: "Run dupe check" }).click(); + await expect(page.getByText("Example.Release.2026.1080p-GRP")).toBeVisible(); + await page.getByLabel("Ignore dupes for HDS").check(); + + await page.getByRole("button", { name: "Screenshots" }).click(); + await page.getByRole("button", { name: "Generate screenshots" }).click(); + await expect(page.getByRole("button", { name: "Screenshot 1" })).toBeVisible(); + await page.getByRole("button", { name: "Upload Images" }).click(); + await expect(page.getByText("1 found")).toBeVisible(); + await page.getByRole("combobox", { name: "Image host" }).selectOption("imgbb"); + await page.getByRole("button", { name: "Upload selected (1)" }).click(); + await page.getByRole("button", { name: "Descriptions" }).click(); + await page.getByRole("button", { name: "Refresh descriptions" }).click(); + + await page.getByRole("button", { name: "Upload", exact: true }).click(); + await page.getByRole("button", { name: "Run dry run" }).click(); + await expect(page.getByText(/min_resolution.*strict|strict.*min_resolution/)).toBeVisible(); + await expect(page.getByText("Duplicate diagnostics")).toBeVisible(); + await expect(page.getByText("Example.Release.2026.1080p-GRP")).toBeVisible(); + await expect(page.getByLabel("Authorize min_resolution for HDS")).toHaveCount(0); + await expect.poll(() => workspace.fake.counters.clientInjections).toBe(1); + expect(workspace.fake.counters.trackerUploads).toBe(0); + + await page.getByLabel("Skip client injection").check(); + await page.getByRole("button", { name: "Run dry run" }).click(); + await expect.poll(() => workspace.fake.counters.clientInjections).toBe(1); + expect(workspace.fake.counters.trackerUploads).toBe(0); + + await page.getByRole("button", { name: "Review upload" }).click(); + await expect(page.getByRole("alert")).toContainText(/eligible|rule/i); + await expect(page.getByRole("button", { name: "Start upload" })).toBeDisabled(); + expect(workspace.fake.counters.trackerUploads).toBe(0); + } finally { + await app?.stop(); + await workspace.cleanup(); + } +}); + +test("embedded web tracks BDMV playlist preparation and opens duplicate checking", async ({ + page, +}) => { + const workspace = await createE2EWorkspace(); + let app: AppServer | undefined; + try { + const sourcePath = await createBluraySourceFixture(workspace); + app = await startApp(workspace); + await page.goto(app.url); + await page.getByLabel("Source path").fill(sourcePath); + await page.getByRole("button", { name: "Fetch metadata" }).click(); + + await expect(page.getByRole("heading", { name: "Select BDMV Playlists" })).toBeVisible(); + await expect(page.getByText("Discover Blu-ray playlists")).toBeVisible(); + await page.getByRole("checkbox", { name: "00001.mpls" }).check(); + await page.getByRole("button", { name: "Confirm Selection" }).click(); + + await expect(page.getByText("E2E.Movie.2026.1080p.WEB-DL")).toBeVisible(); + await expect(page.getByText("Blu-ray analysis complete.")).toHaveCount(0); + await page.getByRole("button", { name: "Dupe Check" }).click(); + await page.getByRole("checkbox", { name: "AITHER" }).check(); + await page.getByRole("button", { name: "Run dupe check" }).click(); + await expect.poll(() => workspace.fake.counters.clientSearches).toBe(1); + } finally { + await app?.stop(); + await workspace.cleanup(); + } +}); diff --git a/webui/e2e/web-smoke.spec.ts b/webui/e2e/web-smoke.spec.ts new file mode 100644 index 00000000..1792c055 --- /dev/null +++ b/webui/e2e/web-smoke.spec.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { expect, test } from "@playwright/test"; +import { createE2EWorkspace, startApp, type AppServer } from "./helpers/e2eHarness"; + +test("embedded web boots with dev auth, navigates core pages, and reports invalid paths", async ({ + page, +}) => { + const workspace = await createE2EWorkspace(); + let app: AppServer | undefined; + try { + app = await startApp(workspace); + await page.goto(app.url); + await expect(page.getByRole("heading", { name: "Build Release Name" })).toBeVisible(); + + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await page.getByRole("button", { name: "Reload" }).click(); + await expect(page.getByText("Configuration")).toBeVisible(); + + await page.getByRole("button", { name: "Logging" }).click(); + await expect(page.getByRole("heading", { name: "Logging" })).toBeVisible(); + + await page.getByRole("button", { name: "History" }).click(); + await expect(page.getByRole("heading", { name: "History" })).toBeVisible(); + + await page.getByRole("button", { name: "Input" }).click(); + await page.getByLabel("Source path").fill("Z:\\missing\\e2e.mkv"); + await page.getByRole("button", { name: "Fetch metadata" }).click(); + const error = page.locator(".error"); + await expect(error).toContainText("The source path is unavailable."); + await expect(error).toContainText("Recovery: edit input."); + await expect(error).not.toContainText("Z:\\missing\\e2e.mkv"); + } finally { + await app?.stop(); + await workspace.cleanup(); + } +}); + +test("embedded tracker settings use the catalog for entries, reset, and unsupported config", async ({ + page, +}) => { + const workspace = await createE2EWorkspace(); + let app: AppServer | undefined; + try { + app = await startApp(workspace); + await page.goto(app.url); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Trackers", exact: true }).click(); + + await expect( + page.getByText("BTN", { exact: true }).filter({ visible: true }).first(), + ).toBeVisible(); + await expect(page.getByText("Unsupported tracker entries")).toBeVisible(); + await expect(page.getByText("OLD", { exact: true })).toBeVisible(); + + const entryControls = page.locator(".settings-map__header .settings-map__controls"); + const trackerSelector = entryControls.locator("select"); + await expect(trackerSelector.locator('option[value="OLD"]')).toHaveCount(0); + await trackerSelector.selectOption("BLU"); + await entryControls.getByRole("button", { name: "Add entry" }).click(); + + let bluCard = page + .locator("details.settings-card") + .filter({ has: page.locator("summary", { hasText: "BLU" }) }); + await expect(bluCard).toBeVisible(); + await expect( + bluCard.locator("label.settings-field > span, .settings-switch-row > span"), + ).toHaveText(["API key", "Anonymous", "Image host"]); + await bluCard.getByLabel("API key").fill("e2e-blu-activation"); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Settings saved and applied.")).toBeVisible(); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + + bluCard = page + .locator("details.settings-card") + .filter({ has: page.locator("summary", { hasText: "BLU" }) }); + await expect(bluCard).toBeVisible(); + await bluCard.getByRole("button", { name: "Remove" }).click(); + await expect(bluCard).toHaveCount(0); + await expect(trackerSelector.locator('option[value="BLU"]')).toHaveCount(1); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect( + page.locator("details.settings-card").filter({ + has: page.locator("summary", { hasText: "BLU" }), + }), + ).toHaveCount(0); + + const unsupportedCard = page + .locator(".settings-card") + .filter({ has: page.locator(".settings-card__summary-name", { hasText: "OLD" }) }); + await unsupportedCard.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByText("OLD", { exact: true })).toHaveCount(0); + } finally { + await app?.stop(); + await workspace.cleanup(); + } +}); diff --git a/gui/frontend/eslint.config.js b/webui/eslint.config.js similarity index 97% rename from gui/frontend/eslint.config.js rename to webui/eslint.config.js index a8f139d3..afc5ccd1 100644 --- a/gui/frontend/eslint.config.js +++ b/webui/eslint.config.js @@ -6,7 +6,7 @@ import prettier from "eslint-config-prettier"; export default [ { - ignores: ["dist/**", "node_modules/**", "wailsjs/**"], + ignores: ["dist/**", "node_modules/**"], }, { files: ["src/**/*.{ts,tsx}"], diff --git a/gui/frontend/index.html b/webui/index.html similarity index 100% rename from gui/frontend/index.html rename to webui/index.html diff --git a/gui/frontend/knip.ts b/webui/knip.ts similarity index 100% rename from gui/frontend/knip.ts rename to webui/knip.ts diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 00000000..fd08438d --- /dev/null +++ b/webui/package.json @@ -0,0 +1,65 @@ +{ + "name": "upbrr-webui", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@10.27.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:bundle": "vite build", + "preview": "vite preview", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:e2e:web": "playwright test --project=web-smoke --project=web-base-path-smoke --project=web-full-upload", + "test:e2e:full": "playwright test", + "lint": "eslint --cache \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint --cache --fix src --ext .ts,.tsx", + "lint:style": "stylelint \"src/**/*.css\"", + "lint:style:fix": "stylelint \"src/**/*.css\" --fix", + "lint:dead": "knip --include files,exports,dependencies --no-config-hints", + "typecheck": "tsc --noEmit", + "format": "prettier --write --cache .", + "format:check": "prettier --check --cache ." + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-switch": "^1.2.6", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.0", + "eslint": "^10.2.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "happy-dom": "^20.9.0", + "knip": "^6.4.1", + "postcss": "^8.5.15", + "prettier": "^3.8.2", + "stylelint": "^16.26.1", + "stylelint-config-recommended": "^17.0.0", + "tailwindcss": "^3.4.19", + "typescript": "^5.6.3", + "vite": "^8.0.16", + "vitest": "^4.1.8" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/gui/frontend/playwright.config.ts b/webui/playwright.config.ts similarity index 92% rename from gui/frontend/playwright.config.ts rename to webui/playwright.config.ts index 0f98e54f..34619503 100644 --- a/gui/frontend/playwright.config.ts +++ b/webui/playwright.config.ts @@ -38,9 +38,5 @@ export default defineConfig({ name: "cli-full-upload", testMatch: /cli-full-upload\.spec\.ts/, }, - { - name: "wails-basic", - testMatch: /wails-basic\.spec\.ts/, - }, ], }); diff --git a/gui/frontend/pnpm-lock.yaml b/webui/pnpm-lock.yaml similarity index 100% rename from gui/frontend/pnpm-lock.yaml rename to webui/pnpm-lock.yaml diff --git a/gui/frontend/postcss.config.js b/webui/postcss.config.js similarity index 100% rename from gui/frontend/postcss.config.js rename to webui/postcss.config.js diff --git a/gui/frontend/public/apple-touch-icon.png b/webui/public/apple-touch-icon.png similarity index 100% rename from gui/frontend/public/apple-touch-icon.png rename to webui/public/apple-touch-icon.png diff --git a/gui/frontend/public/favicon-16x16.png b/webui/public/favicon-16x16.png similarity index 100% rename from gui/frontend/public/favicon-16x16.png rename to webui/public/favicon-16x16.png diff --git a/gui/frontend/public/favicon-32x32.png b/webui/public/favicon-32x32.png similarity index 100% rename from gui/frontend/public/favicon-32x32.png rename to webui/public/favicon-32x32.png diff --git a/gui/frontend/public/favicon.ico b/webui/public/favicon.ico similarity index 100% rename from gui/frontend/public/favicon.ico rename to webui/public/favicon.ico diff --git a/gui/frontend/public/icon-192.png b/webui/public/icon-192.png similarity index 100% rename from gui/frontend/public/icon-192.png rename to webui/public/icon-192.png diff --git a/gui/frontend/public/icon-512.png b/webui/public/icon-512.png similarity index 100% rename from gui/frontend/public/icon-512.png rename to webui/public/icon-512.png diff --git a/gui/frontend/public/site.webmanifest b/webui/public/site.webmanifest similarity index 100% rename from gui/frontend/public/site.webmanifest rename to webui/public/site.webmanifest diff --git a/webui/src/api/app.ts b/webui/src/api/app.ts new file mode 100644 index 00000000..16de0b19 --- /dev/null +++ b/webui/src/api/app.ts @@ -0,0 +1,516 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { + ApplicationInfo, + BrowseDirectoryResponse, + DescriptionBuilderPreview, + DVDMenuCaptureResult, + DupeCheckSnapshot, + ExternalIDOverrides, + HistoryEntry, + HistoryOverview, + ImageHostPolicyMetadata, + MetadataPreview, + OwnerJobSnapshot, + PlaylistInfo, + PlaylistInstruction, + PreparationPreview, + PrepareInput, + PrepareResult, + ReleaseNameOverrides, + ReleaseRef, + ScreenshotImage, + ScreenshotPlan, + ScreenshotPurpose, + ScreenshotResult, + ScreenshotSelection, + TrackerAuthCapability, + TrackerAuthLoginRequest, + TrackerAuthStatus, + TrackerCatalog, + TrackerDryRunPreview, + RuleAuthorization, + TrackerUploadSnapshot, + UploadedImageLink, + UploadImagesResult, + UploadReviewResult, +} from "../types"; +import { requestApp } from "./client"; + +type LogEntry = { + ID: number; + Time: string; + Level: string; + Message: string; +}; + +type ConfigImportResult = { message: string; warnings: string[] }; + +const maxCookieImportContentBytes = 1024 * 1024; +const encodedTextByteLength = (value: string) => new TextEncoder().encode(value).length; + +const selectTextFile = (accept: string) => + new Promise<{ name: string; content: string }>((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + resolve({ name: "", content: "" }); + return; + } + const reader = new FileReader(); + reader.onload = () => resolve({ name: file.name, content: reader.result as string }); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }; + input.addEventListener("cancel", () => resolve({ name: "", content: "" })); + input.click(); + }); + +/** Host filesystem browsing exposed through the authenticated WebUI route. */ +export const hostBrowser = { + list: (path: string, mode: "file" | "folder") => + requestApp("BrowseDirectory", { path, mode }), +}; + +/** Application build and runtime capability information. */ +export const applicationClient = { + getInfo: () => requestApp("GetApplicationInfo"), +}; + +/** Canonical release preparation and metadata preview operations. */ +export const preparationClient = { + detectDiscType: (path: string, signal?: AbortSignal) => + requestApp("DetectDiscType", { Path: path }, { signal }), + fetchMetadata: ( + correlationID: string, + path: string, + sourceLookupURL: string, + overrides: ExternalIDOverrides, + nameOverrides: ReleaseNameOverrides, + playlist: PlaylistInstruction, + confirmBDMVRescan: boolean, + signal?: AbortSignal, + ) => + requestApp( + "FetchMetadata", + { + CorrelationID: correlationID, + Path: path, + SourceLookupURL: sourceLookupURL, + Overrides: overrides, + NameOverrides: nameOverrides, + Playlist: playlist, + ConfirmBDMVRescan: confirmBDMVRescan, + }, + { signal }, + ), + prepareRelease: (input: PrepareInput) => + requestApp("PrepareRelease", { Input: input }), + resetMetadata: ( + correlationID: string, + path: string, + sourceLookupURL: string, + overrides: ExternalIDOverrides, + nameOverrides: ReleaseNameOverrides, + playlist: PlaylistInstruction, + confirmBDMVRescan: boolean, + signal?: AbortSignal, + ) => + requestApp( + "ResetMetadata", + { + CorrelationID: correlationID, + Path: path, + SourceLookupURL: sourceLookupURL, + Overrides: overrides, + NameOverrides: nameOverrides, + Playlist: playlist, + ConfirmBDMVRescan: confirmBDMVRescan, + }, + { signal }, + ), + selectBlurayCandidate: ( + correlationID: string, + path: string, + releaseID: string, + signal?: AbortSignal, + ) => + requestApp( + "SelectBlurayCandidate", + { CorrelationID: correlationID, Path: path, ReleaseID: releaseID }, + { signal }, + ), + fetchDescriptionBuilder: (release: ReleaseRef, trackers: string[], signal?: AbortSignal) => + requestApp( + "FetchDescriptionBuilder", + { + Release: release, + Trackers: trackers, + }, + { signal }, + ), + fetchPreparation: ( + path: string, + overrides: ExternalIDOverrides, + nameOverrides: ReleaseNameOverrides, + trackers: string[], + ignoreDupesFor: string[], + ) => + requestApp("FetchPreparation", { + Path: path, + Overrides: overrides, + NameOverrides: nameOverrides, + Trackers: trackers, + IgnoreDupesFor: ignoreDupesFor, + }), + fetchTrackerDryRun: ( + dupeJobID: string, + release: ReleaseRef, + trackers: string[], + ignoreDupesFor: string[], + questionnaireAnswers: Record>, + descriptionGroups: DescriptionBuilderPreview["Groups"], + noSeed: boolean, + runLogLevel: string, + signal?: AbortSignal, + ) => + requestApp( + "FetchTrackerDryRun", + { + DupeJobID: dupeJobID, + Release: release, + Trackers: trackers, + IgnoreDupesFor: ignoreDupesFor, + QuestionnaireAnswers: questionnaireAnswers, + DescriptionGroups: descriptionGroups, + NoSeed: noSeed, + RunLogLevel: runLogLevel, + }, + { signal }, + ), +}; + +/** BDMV playlist discovery for one selected preparation source. */ +export const playlistClient = { + discover: (path: string, signal?: AbortSignal) => + requestApp("DiscoverPlaylists", { Path: path }, { signal }), +}; + +/** Session-owned duplicate-check jobs. */ +export const dupeClient = { + start: (release: ReleaseRef, trackers: string[], correlationID: string) => + requestApp("StartDupeCheck", { + Release: release, + Trackers: trackers, + CorrelationID: correlationID, + }), + cancel: (jobID: string) => requestApp("CancelDupeCheck", { JobID: jobID }), + getSnapshot: (jobID: string) => + requestApp("GetDupeCheckSnapshot", { JobID: jobID }), +}; + +/** Screenshot planning, rendering, selection, and removal operations. */ +export const screenshotClient = { + fetchPlan: (release: ReleaseRef, signal?: AbortSignal) => + requestApp("FetchScreenshotPlan", { Release: release }, { signal }), + generate: ( + release: ReleaseRef, + selections: ScreenshotSelection[], + purpose: ScreenshotPurpose, + signal?: AbortSignal, + ) => + requestApp( + "GenerateScreenshots", + { + Release: release, + Selections: selections, + Purpose: purpose, + }, + { signal }, + ), + previewFrame: (release: ReleaseRef, timestampSeconds: number, signal?: AbortSignal) => + requestApp( + "PreviewScreenshotFrame", + { + Release: release, + TimestampSeconds: timestampSeconds, + }, + { signal }, + ), + remove: (release: ReleaseRef, imagePath: string, signal?: AbortSignal) => + requestApp( + "DeleteScreenshot", + { + Release: release, + ImagePath: imagePath, + }, + { signal }, + ), + saveFinalSelections: (release: ReleaseRef, images: ScreenshotImage[], signal?: AbortSignal) => + requestApp( + "SaveFinalScreenshotSelections", + { + Release: release, + Images: images, + }, + { signal }, + ), + readImage: (path: string, signal?: AbortSignal) => + requestApp("ReadScreenshotImage", { Path: path }, { signal }), + deleteTrackerImageURL: (release: ReleaseRef, url: string, signal?: AbortSignal) => + requestApp( + "DeleteTrackerImageURL", + { + Release: release, + URL: url, + }, + { signal }, + ), +}; + +/** DVD menu capture and persisted menu-image operations. */ +export const menuImageClient = { + importPaths: (release: ReleaseRef, paths: string[], signal?: AbortSignal) => + requestApp( + "ImportMenuImages", + { + Release: release, + Paths: paths, + }, + { signal }, + ), + capture: (release: ReleaseRef, signal?: AbortSignal) => + requestApp("CaptureDVDMenus", { Release: release }, { signal }), + list: (release: ReleaseRef, signal?: AbortSignal) => + requestApp("ListDVDMenuScreenshots", { Release: release }, { signal }), + remove: (release: ReleaseRef, imagePath: string, signal?: AbortSignal) => + requestApp( + "DeleteDVDMenuScreenshot", + { + Release: release, + ImagePath: imagePath, + }, + { signal }, + ), +}; + +/** Image-host upload state and operations. */ +export const uploadedImageClient = { + listCandidates: (release: ReleaseRef, signal?: AbortSignal) => + requestApp("ListUploadCandidates", { Release: release }, { signal }), + listUploaded: (release: ReleaseRef, signal?: AbortSignal) => + requestApp("ListUploadedImages", { Release: release }, { signal }), + upload: ( + correlationID: string, + release: ReleaseRef, + trackers: string[], + host: string, + images: ScreenshotImage[], + signal?: AbortSignal, + ) => + requestApp( + "UploadImages", + { + CorrelationID: correlationID, + Release: release, + Trackers: trackers, + Host: host, + Images: images, + }, + { signal }, + ), + remove: (release: ReleaseRef, imagePath: string, host: string, signal?: AbortSignal) => + requestApp( + "DeleteUploadedImage", + { Release: release, ImagePath: imagePath, Host: host }, + { signal }, + ), +}; + +/** Description rendering and override persistence. */ +export const descriptionClient = { + render: (raw: string, signal?: AbortSignal) => + requestApp("RenderDescription", { Raw: raw }, { signal }), + saveOverride: ( + release: ReleaseRef, + groupKey: string, + raw: string, + trackers: string[], + signal?: AbortSignal, + ) => + requestApp( + "SaveDescriptionOverride", + { + Release: release, + GroupKey: groupKey, + Raw: raw, + Trackers: trackers, + }, + { signal }, + ), +}; + +/** Config persistence plus browser-native import and download behavior. */ +export const configClient = { + get: () => requestApp("GetConfig"), + getDefault: () => requestApp("GetDefaultConfig"), + save: (payload: string) => requestApp("SaveConfig", { Payload: payload }), + exportDownload: async () => { + const payload = await requestApp("ExportConfig"); + const blob = new Blob([payload], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "upbrr-config.json"; + anchor.click(); + URL.revokeObjectURL(url); + return anchor.download; + }, + importFile: async (): Promise => { + const fileData = await selectTextFile(".py,.yaml,.yml,.json"); + if (!fileData.name) return { message: "", warnings: [] }; + const response = await requestApp<{ result: string; warnings: string[] }>("ImportConfig", { + FileName: fileData.name, + FileContent: fileData.content, + }); + return { message: response.result, warnings: response.warnings ?? [] }; + }, +}; + +/** Sanitized recent-log and live-log stream operations. */ +export const loggingClient = { + getPath: () => requestApp("GetLogPath"), + getRecent: (limit: number) => requestApp("GetRecentLogs", { Limit: limit }), + startStream: () => requestApp("StartLogStream"), + stopStream: (streamID: string) => requestApp("StopLogStream", { StreamID: streamID }), + getExclusions: () => requestApp("GetLogExclusions"), + updateExclusions: (patterns: string[]) => + requestApp("UpdateLogExclusions", { Patterns: patterns }), +}; + +/** Tracker catalog and image-host policy metadata. */ +export const trackerCatalogClient = { + list: () => requestApp("ListTrackerCatalog"), + getImageHostPolicyMetadata: () => + requestApp("GetImageHostPolicyMetadata"), + getIcon: (domain: string, url: string) => + requestApp("GetTrackerIcon", { Domain: domain, URL: url }), +}; + +/** Tracker authentication status, browser file import, login, 2FA, and removal. */ +export const trackerAuthClient = { + listCapabilities: () => requestApp("ListTrackerAuthCapabilities"), + getStatus: (tracker: string) => + requestApp("GetTrackerAuthStatus", { Tracker: tracker }), + importCookies: async (tracker: string) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".txt,.json"; + const fileData = await new Promise<{ name: string; content: string }>((resolve, reject) => { + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + resolve({ name: "", content: "" }); + return; + } + if (file.size > maxCookieImportContentBytes) { + reject( + new Error( + `tracker auth: cookie file content exceeds ${maxCookieImportContentBytes} byte limit`, + ), + ); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + if (encodedTextByteLength(content) > maxCookieImportContentBytes) { + reject( + new Error( + `tracker auth: cookie file content exceeds ${maxCookieImportContentBytes} byte limit`, + ), + ); + return; + } + resolve({ name: file.name, content }); + }; + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }; + input.addEventListener("cancel", () => resolve({ name: "", content: "" })); + input.click(); + }); + if (!fileData.name) return trackerAuthClient.getStatus(tracker); + return trackerAuthClient.importCookieContent(tracker, fileData.name, fileData.content); + }, + importCookieContent: (tracker: string, fileName: string, content: string) => + requestApp("ImportTrackerAuthCookieContent", { + Tracker: tracker, + FileName: fileName, + Content: content, + }), + test: (tracker: string) => requestApp("TestTrackerAuth", { Tracker: tracker }), + login: (tracker: string, login: TrackerAuthLoginRequest) => + requestApp("LoginTrackerAuth", { Tracker: tracker, Login: login }), + submit2FA: (challengeID: string, code: string) => + requestApp("SubmitTrackerAuth2FA", { ChallengeID: challengeID, Code: code }), + remove: (tracker: string) => + requestApp("DeleteTrackerAuth", { Tracker: tracker }), +}; + +/** Persisted release history operations. */ +export const historyClient = { + list: () => requestApp("ListHistory"), + getOverview: (sourcePath: string) => + requestApp("GetHistoryOverview", { SourcePath: sourcePath }), + removeRelease: (sourcePath: string) => + requestApp("DeleteHistoryRelease", { SourcePath: sourcePath }), +}; + +/** Reviewed tracker-upload handoff and session-owned job lifecycle. */ +export const uploadClient = { + review: ( + release: ReleaseRef, + trackers: string[], + ignoreDupesFor: string[], + ruleAuthorizations: RuleAuthorization[], + questionnaireAnswers: Record>, + descriptionGroups: DescriptionBuilderPreview["Groups"], + noSeed: boolean, + runLogLevel: string, + signal?: AbortSignal, + ) => + requestApp( + "ReviewTrackerUpload", + { + Release: release, + Trackers: trackers, + IgnoreDupesFor: ignoreDupesFor, + RuleAuthorizations: ruleAuthorizations, + QuestionnaireAnswers: questionnaireAnswers, + DescriptionGroups: descriptionGroups, + NoSeed: noSeed, + RunLogLevel: runLogLevel, + }, + { signal }, + ), + startReviewed: (token: string, correlationID: string) => + requestApp("StartReviewedTrackerUpload", { + Token: token, + CorrelationID: correlationID, + }), + cancel: (jobID: string) => requestApp("CancelTrackerUpload", { JobID: jobID }), + retryFailed: (jobID: string, correlationID: string) => + requestApp("RetryFailedTrackerUpload", { JobID: jobID, CorrelationID: correlationID }), + getSnapshot: (jobID: string) => + requestApp("GetTrackerUploadSnapshot", { JobID: jobID }), +}; + +/** Authenticated-owner retained Job listing. */ +export const jobsClient = { + list: () => requestApp("ListJobs"), +}; diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts new file mode 100644 index 00000000..67889b5a --- /dev/null +++ b/webui/src/api/client.test.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TrackerAuthStatus } from "../types"; + +const jsonResponse = (payload: unknown, init?: ResponseInit) => + new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + ...init, + }); + +const eventStreamResponse = (payload: unknown, onCancel?: () => void) => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`event: test:event\ndata: ${JSON.stringify(payload)}\n\n`)); + }, + cancel() { + onCancel?.(); + }, + }); + return new Response(stream, { + headers: { "Content-Type": "text/event-stream" }, + }); +}; + +describe("web client", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + delete window.__UPBRR_BASE_URL__; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("posts app calls with JSON and CSRF headers", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { screenshotClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + const release = { SourcePath: "C:/media/Example.mkv", Generation: 3 }; + const result = await screenshotClient.fetchPlan(release); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/app/FetchScreenshotPlan", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": "csrf-token", + }, + body: JSON.stringify({ + Release: release, + }), + }), + ); + }); + + it("uses the abortable exact DVD menu capture payload", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ Images: [], Warnings: [] })) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { menuImageClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + const controller = new AbortController(); + const release = { SourcePath: "C:/media/Example", Generation: 3 }; + await menuImageClient.capture(release, controller.signal); + await menuImageClient.list(release); + await menuImageClient.remove(release, "C:/managed/menu.png"); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/app/CaptureDVDMenus", + expect.objectContaining({ + signal: controller.signal, + body: JSON.stringify({ + Release: release, + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/app/ListDVDMenuScreenshots", + expect.objectContaining({ + body: JSON.stringify({ + Release: release, + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/app/DeleteDVDMenuScreenshot", + expect.objectContaining({ + body: JSON.stringify({ + Release: release, + ImagePath: "C:/managed/menu.png", + }), + }), + ); + }); + + it("preserves tracker auth status fields from browser app routes", async () => { + const status: TrackerAuthStatus = { + trackerID: "MTV", + displayName: "MTV", + state: "needs_2fa", + cookieCount: 2, + lastCheckedAt: "2026-07-08T01:02:03Z", + lastError: "tracker auth validation failed", + encryptedStorage: true, + needs2FA: true, + challengeID: "challenge-123", + message: "enter 2FA code", + }; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(status)); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { trackerAuthClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + const result = await trackerAuthClient.getStatus("MTV"); + + expect(result).toEqual(status); + expect(fetchMock).toHaveBeenCalledWith( + "/api/app/GetTrackerAuthStatus", + expect.objectContaining({ + body: JSON.stringify({ Tracker: "MTV" }), + }), + ); + }); + + it("preserves tracker auth app route error messages", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse({ error: "tracker auth: validation failed" }, { status: 400 }), + ), + ); + + const { initializeWebClient } = await import("./client"); + const { trackerAuthClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + await expect(trackerAuthClient.test("MTV")).rejects.toThrow("tracker auth: validation failed"); + }); + + it("renders structured operation failures with stable recovery guidance", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse( + { + error: "Prepared release changed.", + failure: { + Code: "stale_generation", + Operation: "media", + Message: "Prepared release changed.", + Recovery: "refresh_release", + }, + }, + { status: 409 }, + ), + ), + ); + + const { initializeWebClient, requestApp } = await import("./client"); + initializeWebClient("csrf-token", true); + + await expect(requestApp("FetchScreenshotPlan", {})).rejects.toThrow( + "Prepared release changed. Recovery: refresh release.", + ); + }); + + it("prefixes browser API calls with the injected base URL", async () => { + window.__UPBRR_BASE_URL__ = "/upbrr/"; + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ ok: true }))); + vi.stubGlobal("fetch", fetchMock); + + const { authClient, initializeWebClient, withBasePath } = await import("./client"); + const { configClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + expect(withBasePath("/api/auth/status")).toBe("/upbrr/api/auth/status"); + + await authClient.status(); + await configClient.get(); + + expect(fetchMock).toHaveBeenNthCalledWith(1, "/upbrr/api/auth/status", { + credentials: "include", + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/upbrr/api/app/GetConfig", + expect.objectContaining({ + method: "POST", + credentials: "include", + }), + ); + }); + + it("throws response errors from browser auth calls", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ error: "login required" }, { status: 401 })), + ); + + const { authClient } = await import("./client"); + + await expect(authClient.status()).rejects.toThrow("login required"); + }); + + it("refreshes browser auth state and retries app calls once", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ error: "csrf validation failed" }, { status: 403 })) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + csrfToken: "stale-csrf", + }), + ) + .mockResolvedValueOnce(jsonResponse("job-1")); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { dupeClient } = await import("./app"); + initializeWebClient("stale-csrf", false); + + const result = await dupeClient.start( + { SourcePath: "C:/media/movie.mkv", Generation: 1 }, + ["AITHER"], + "dupe-correlation", + ); + + expect(result).toBe("job-1"); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/app/StartDupeCheck", + expect.objectContaining({ + credentials: "include", + headers: expect.objectContaining({ "X-CSRF-Token": "stale-csrf" }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/auth/status", { + credentials: "include", + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "/api/app/StartDupeCheck", + expect.objectContaining({ + credentials: "include", + headers: expect.objectContaining({ "X-CSRF-Token": "stale-csrf" }), + }), + ); + }); + + it("does not adopt a different browser session during auth refresh", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ error: "csrf validation failed" }, { status: 403 })) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + csrfToken: "other-session-csrf", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { dupeClient } = await import("./app"); + initializeWebClient("session-a-csrf", false); + + await expect( + dupeClient.start( + { SourcePath: "C:/media/movie.mkv", Generation: 1 }, + ["AITHER"], + "dupe-correlation", + ), + ).rejects.toThrow("Web session changed in another tab"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("opens browser events with the initialized session token header", async () => { + const cancelStream = vi.fn(); + const fetchMock = vi + .fn() + .mockResolvedValue(eventStreamResponse({ jobID: "job-1" }, cancelStream)); + vi.stubGlobal("fetch", fetchMock); + + const { subscribeWebEvent, initializeWebClient } = await import("./client"); + initializeWebClient("csrf-token", true); + const listener = vi.fn(); + const off = subscribeWebEvent("test:event", listener); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/events", + expect.objectContaining({ + method: "GET", + credentials: "include", + headers: { "X-CSRF-Token": "csrf-token" }, + }), + ); + await vi.waitFor(() => expect(listener).toHaveBeenCalledWith({ jobID: "job-1" })); + off(); + await vi.waitFor(() => expect(cancelStream).toHaveBeenCalledOnce()); + }); + + it("stores host path case sensitivity from client initialization", async () => { + const { initializeWebClient, isHostPathCaseInsensitive } = await import("./client"); + + initializeWebClient("csrf-token", true); + expect(isHostPathCaseInsensitive()).toBe(true); + + initializeWebClient("csrf-token", false); + expect(isHostPathCaseInsensitive()).toBe(false); + }); + + it("rejects oversized decoded tracker cookie content before posting", async () => { + const originalCreateElement = document.createElement.bind(document); + const input = document.createElement("input"); + Object.defineProperty(input, "files", { + configurable: true, + value: [new File(["x"], "cookies.txt")], + }); + vi.spyOn(input, "click").mockImplementation(() => { + input.dispatchEvent(new Event("change")); + }); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName === "input") { + return input; + } + return originalCreateElement(tagName); + }); + const readAsText = vi.fn(); + vi.stubGlobal( + "FileReader", + vi.fn().mockImplementation(function (this: any) { + this.readAsText = readAsText.mockImplementation(() => { + Object.defineProperty(this, "result", { + configurable: true, + value: "x".repeat(1024 * 1024 + 1), + }); + this.onload?.(new ProgressEvent("load")); + }); + }), + ); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { trackerAuthClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + await expect(trackerAuthClient.importCookies("PTP")).rejects.toThrow( + "cookie file content exceeds 1048576 byte limit", + ); + expect(readAsText).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects oversized raw tracker cookie files before decoding", async () => { + const originalCreateElement = document.createElement.bind(document); + const input = document.createElement("input"); + const file = new File(["x"], "cookies.txt"); + Object.defineProperty(file, "size", { configurable: true, value: 1024 * 1024 + 1 }); + Object.defineProperty(input, "files", { + configurable: true, + value: [file], + }); + vi.spyOn(input, "click").mockImplementation(() => { + input.dispatchEvent(new Event("change")); + }); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName === "input") { + return input; + } + return originalCreateElement(tagName); + }); + const readAsText = vi.fn(); + vi.stubGlobal( + "FileReader", + vi.fn().mockImplementation(function (this: any) { + this.readAsText = readAsText.mockImplementation(() => { + Object.defineProperty(this, "result", { configurable: true, value: "session=abc" }); + this.onload?.(new ProgressEvent("load")); + }); + }), + ); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ trackerID: "PTP" })); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { trackerAuthClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + await expect(trackerAuthClient.importCookies("PTP")).rejects.toThrow( + "cookie file content exceeds 1048576 byte limit", + ); + expect(readAsText).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("posts valid tracker cookie files from the browser client", async () => { + const originalCreateElement = document.createElement.bind(document); + const input = document.createElement("input"); + Object.defineProperty(input, "files", { + configurable: true, + value: [new File(["session=abc"], "cookies.txt")], + }); + vi.spyOn(input, "click").mockImplementation(() => { + input.dispatchEvent(new Event("change")); + }); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName === "input") { + return input; + } + return originalCreateElement(tagName); + }); + vi.stubGlobal( + "FileReader", + vi.fn().mockImplementation(function (this: any) { + this.readAsText = vi.fn(() => { + Object.defineProperty(this, "result", { configurable: true, value: "session=abc" }); + this.onload?.(new ProgressEvent("load")); + }); + }), + ); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ trackerID: "PTP" })); + vi.stubGlobal("fetch", fetchMock); + + const { initializeWebClient } = await import("./client"); + const { trackerAuthClient } = await import("./app"); + initializeWebClient("csrf-token", true); + + await expect(trackerAuthClient.importCookies("PTP")).resolves.toEqual({ + trackerID: "PTP", + }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/app/ImportTrackerAuthCookieContent", + expect.objectContaining({ + body: JSON.stringify({ + Tracker: "PTP", + FileName: "cookies.txt", + Content: "session=abc", + }), + }), + ); + }); +}); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts new file mode 100644 index 00000000..60ae8f3c --- /dev/null +++ b/webui/src/api/client.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { OperationFailure } from "../types"; + +type EventCallback = (payload: unknown) => void; +type AppRequestHandler = ( + method: string, + body?: unknown, + options?: { signal?: AbortSignal }, +) => Promise; + +declare global { + interface Window { + __UPBRR_BASE_URL__?: string; + } +} + +const callbacks = new Map>(); +let eventStreamController: AbortController | null = null; +let eventStreamReconnectTimer: ReturnType | null = null; +let csrfToken = ""; +let caseInsensitivePaths = navigator.platform.toLowerCase().startsWith("win"); +let testAppRequestHandler: AppRequestHandler | null = null; + +const sessionChangedMessage = + "Web session changed in another tab. Reload this tab to continue with the active login."; + +const parseJSONResponse = async (response: Response): Promise => { + const text = await response.text(); + if (!text.trim()) return null; + return JSON.parse(text) as T; +}; + +/** Stable domain failure returned by an accepted application operation. */ +class OperationFailureError extends Error { + readonly failure: OperationFailure; + + constructor(failure: OperationFailure) { + super( + failure.Recovery && failure.Recovery !== "none" + ? `${failure.Message} Recovery: ${failure.Recovery.replaceAll("_", " ")}.` + : failure.Message, + ); + this.name = "OperationFailureError"; + this.failure = failure; + } +} + +const isAuthFailureStatus = (status: number) => status === 401 || status === 403; + +const normalizeBaseURL = (value: unknown) => { + if (typeof value !== "string") return "/"; + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") return "/"; + const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return path.endsWith("/") ? path : `${path}/`; +}; + +/** Prefixes API and event paths with the base URL injected by the WebUI server. */ +export const withBasePath = (path: string) => { + const normalizedPath = path.startsWith("/") ? path.slice(1) : path; + return `${normalizeBaseURL(window.__UPBRR_BASE_URL__)}${normalizedPath}`; +}; + +const setPathCaseSensitivity = (caseInsensitive: unknown) => { + if (typeof caseInsensitive === "boolean") caseInsensitivePaths = caseInsensitive; +}; + +const refreshAuthState = async () => { + const response = await fetch(withBasePath("/api/auth/status"), { credentials: "include" }); + const payload = await parseJSONResponse< + Record & { + authenticated?: boolean; + csrfToken?: string; + caseInsensitivePaths?: boolean; + } + >(response); + if (!response.ok || !payload?.authenticated) return false; + + const nextCSRFToken = String(payload.csrfToken || ""); + if (csrfToken && nextCSRFToken && nextCSRFToken !== csrfToken) { + throw new Error(sessionChangedMessage); + } + csrfToken = nextCSRFToken; + setPathCaseSensitivity(payload.caseInsensitivePaths); + recreateEventStream(); + return csrfToken !== ""; +}; + +const closeEventStream = () => { + if (eventStreamReconnectTimer) { + clearTimeout(eventStreamReconnectTimer); + eventStreamReconnectTimer = null; + } + if (eventStreamController) { + eventStreamController.abort(); + eventStreamController = null; + } +}; + +const ensureEventStream = () => { + if (eventStreamController || !csrfToken || callbacks.size === 0) return; + const controller = new AbortController(); + eventStreamController = controller; + void runEventStream(controller); +}; + +const recreateEventStream = () => { + closeEventStream(); + ensureEventStream(); +}; + +const scheduleEventStreamReconnect = () => { + if (!csrfToken || callbacks.size === 0 || eventStreamReconnectTimer) return; + eventStreamReconnectTimer = setTimeout(() => { + eventStreamReconnectTimer = null; + ensureEventStream(); + }, 1000); +}; + +const runEventStream = async (controller: AbortController) => { + let reconnect = true; + try { + const response = await fetch(withBasePath("/api/events"), { + method: "GET", + credentials: "include", + headers: { "X-CSRF-Token": csrfToken }, + signal: controller.signal, + }); + if (!response.ok) { + if (isAuthFailureStatus(response.status)) { + reconnect = await refreshAuthState().catch(() => false); + } + return; + } + if (!response.body) throw new Error("Event stream response body is unavailable"); + await readEventStream(response.body, controller.signal); + } catch (_error) { + if (!controller.signal.aborted) scheduleEventStreamReconnect(); + } finally { + if (eventStreamController === controller) { + eventStreamController = null; + if (reconnect && !controller.signal.aborted) scheduleEventStreamReconnect(); + } + } +}; + +const readEventStream = async (body: ReadableStream, signal: AbortSignal) => { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const abortRead = () => void reader.cancel(); + signal.addEventListener("abort", abortRead, { once: true }); + try { + for (;;) { + if (signal.aborted) break; + const { value, done } = await reader.read(); + if (done || signal.aborted) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() || ""; + parts.forEach(dispatchEventBlock); + } + } finally { + signal.removeEventListener("abort", abortRead); + reader.releaseLock(); + } +}; + +const dispatchEventBlock = (block: string) => { + let eventName = "message"; + const data: string[] = []; + for (const line of block.split(/\r?\n/)) { + if (line.startsWith("event:")) eventName = line.slice("event:".length).trim(); + else if (line.startsWith("data:")) data.push(line.slice("data:".length).trimStart()); + } + if (!callbacks.has(eventName) || data.length === 0) return; + const payload = JSON.parse(data.join("\n")); + callbacks.get(eventName)?.forEach((callback) => callback(payload)); +}; + +const postJSON = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { + const requestInit = (): RequestInit => ({ + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + let response = await fetch(withBasePath(path), requestInit()); + let payload = await parseJSONResponse( + response, + ); + if (!response.ok && isAuthFailureStatus(response.status) && (await refreshAuthState())) { + response = await fetch(withBasePath(path), requestInit()); + payload = await parseJSONResponse(response); + } + if (!response.ok) { + if (payload?.failure) throw new OperationFailureError(payload.failure); + throw new Error(String(payload?.error || response.statusText || "Request failed")); + } + if (payload === null) throw new Error("Request returned an empty response"); + return payload as T; +}; + +/** Initializes cookie-bound WebUI requests and event delivery for one authenticated session. */ +export const initializeWebClient = (token: string, hostCaseInsensitivePaths?: boolean) => { + csrfToken = token; + setPathCaseSensitivity(hostCaseInsensitivePaths); + recreateEventStream(); +}; + +/** Updates the active session CSRF token and host path comparison semantics. */ +export const updateWebCSRFToken = (token: string, hostCaseInsensitivePaths?: boolean) => { + csrfToken = token; + setPathCaseSensitivity(hostCaseInsensitivePaths); + recreateEventStream(); +}; + +/** Reports whether the WebUI host compares filesystem paths case-insensitively. */ +export const isHostPathCaseInsensitive = () => caseInsensitivePaths; + +/** Subscribes to a cookie-bound WebUI server-sent event. */ +export const subscribeWebEvent = (eventName: string, callback: EventCallback) => { + const listeners = callbacks.get(eventName) ?? new Set(); + listeners.add(callback); + callbacks.set(eventName, listeners); + ensureEventStream(); + return () => { + listeners.delete(callback); + if (listeners.size === 0) callbacks.delete(eventName); + if (callbacks.size === 0) closeEventStream(); + }; +}; + +/** Sends one typed application operation to the WebUI server. */ +export const requestApp = ( + method: string, + body?: unknown, + options: { signal?: AbortSignal } = {}, +): Promise => { + if (testAppRequestHandler) { + return testAppRequestHandler(method, body, options).then((result) => result as T); + } + return postJSON(`/api/app/${method}`, body, options.signal); +}; + +/** Installs a request transport for unit tests without creating runtime globals. */ +export const setAppRequestHandlerForTests = (handler: AppRequestHandler | null) => { + testAppRequestHandler = handler; +}; + +/** Auth operations sharing the WebUI base path, cookies, and CSRF lifecycle. */ +export const authClient = { + status: async () => { + const response = await fetch(withBasePath("/api/auth/status"), { credentials: "include" }); + const payload = await parseJSONResponse & { error?: string }>(response); + if (!response.ok) { + throw new Error(String(payload?.error || response.statusText || "Request failed")); + } + return payload || {}; + }, + bootstrap: (username: string, password: string, retainLogin: boolean) => + postJSON("/api/auth/bootstrap", { username, password, retainLogin }), + login: (username: string, password: string, retainLogin: boolean) => + postJSON("/api/auth/login", { username, password, retainLogin }), + saveBrowsePolicy: (browseRoot: string, allowUnrestrictedBrowse: boolean) => + postJSON("/api/auth/browse-policy", { browseRoot, allowUnrestrictedBrowse }), + logout: () => postJSON("/api/auth/logout"), +}; diff --git a/webui/src/app.test.ts b/webui/src/app.test.ts new file mode 100644 index 00000000..df1c1e8b --- /dev/null +++ b/webui/src/app.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { createElement } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import App from "./app"; +import { setAppRequestHandlerForTests } from "./api/client"; +import type { MetadataPreview, TrackerCatalog } from "./types"; +import { emptyExternalIdentity } from "./utils/canonicalIdentity"; +import { sourcePathHistoryStorageKey } from "./utils/inputHistory"; + +const storedValues = new Map(); +const localStorageStub: Storage = { + get length() { + return storedValues.size; + }, + clear: () => storedValues.clear(), + getItem: (key) => storedValues.get(key) ?? null, + key: (index) => Array.from(storedValues.keys())[index] ?? null, + removeItem: (key) => storedValues.delete(key), + setItem: (key, value) => storedValues.set(key, value), +}; + +beforeEach(() => { + Object.defineProperty(document.defaultView, "localStorage", { + configurable: true, + value: localStorageStub, + }); +}); + +afterEach(() => { + cleanup(); + setAppRequestHandlerForTests(null); + localStorageStub.clear(); +}); + +const trackerCatalog = (): TrackerCatalog => ({ + entries: ["AITHER", "BLU"].map((name) => ({ + name, + family: "unit3d", + baseURL: `https://${name.toLowerCase()}.example.invalid`, + uploadContentMode: "description", + configured: true, + fields: [{ key: "APIKey", yamlKey: "api_key", default: "", activation: true }], + })), + unsupported: [], +}); + +const metadataPreview = (sourcePath: string): MetadataPreview => ({ + SourcePath: sourcePath, + TrackerName: "", + ReleaseName: "Example.Release.2026.1080p-GRP", + ReleaseNameOverrides: {}, + Release: { SourcePath: sourcePath, Generation: 1 }, + Identity: { ...emptyExternalIdentity(sourcePath), Generation: 1 }, + Display: { ReleaseName: "Example.Release.2026.1080p-GRP", Providers: [] }, + Bluray: null, + Diagnostics: [], + TrackerData: [], + TrackerRuleFailures: {}, +}); + +describe("App shell", () => { + it("composes the release session and renders input routing", async () => { + setAppRequestHandlerForTests(async (method) => { + if (method === "ListJobs") return []; + if (method === "GetConfig" || method === "GetDefaultConfig") return "{}"; + throw new Error(`unexpected app request: ${method}`); + }); + + render(createElement(App, { jobOwnerKey: "test-owner" })); + + expect(screen.getByRole("heading", { name: "Build Release Name" })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Dupe Check" })).toBeDisabled()); + }); + + it("opens the global host-browser dialog from input presentation", async () => { + const browse = vi.fn(async (_path: string) => ({ + currentPath: "C:\\media", + parentPath: "C:\\", + mode: "folder", + entries: [], + })); + setAppRequestHandlerForTests(async (method, body) => { + if (method === "ListJobs") return []; + if (method === "GetConfig" || method === "GetDefaultConfig") return "{}"; + if (method === "BrowseDirectory") return browse((body as { path: string }).path); + throw new Error(`unexpected app request: ${method}`); + }); + + render(createElement(App)); + screen.getByRole("button", { name: "Browse folder" }).click(); + + expect(await screen.findByRole("dialog", { name: "Select folder" })).toBeInTheDocument(); + expect(browse).toHaveBeenCalledOnce(); + }); + + it("starts with a blank source draft while retaining source history", async () => { + localStorageStub.setItem( + sourcePathHistoryStorageKey, + JSON.stringify([{ path: "C:\\media\\Previously.Used.mkv", mode: "file" }]), + ); + setAppRequestHandlerForTests(async (method) => { + if (method === "ListJobs") return []; + if (method === "GetConfig" || method === "GetDefaultConfig") return "{}"; + throw new Error(`unexpected app request: ${method}`); + }); + + render(createElement(App)); + + const sourceInput = screen.getByLabelText("Source path"); + fireEvent.focus(sourceInput); + expect(await screen.findByText("C:\\media\\Previously.Used.mkv")).toBeInTheDocument(); + expect(sourceInput).toHaveValue(""); + }); + + it("selects configured default trackers for each fresh release session", async () => { + setAppRequestHandlerForTests(async (method, body) => { + if (method === "ListJobs") return []; + if (method === "ListTrackerCatalog") return trackerCatalog(); + if (method === "GetDefaultConfig") return "{}"; + if (method === "GetConfig") { + return JSON.stringify({ + Trackers: { + DefaultTrackers: ["AITHER", "BLU"], + Trackers: { + AITHER: { APIKey: "configured" }, + BLU: { APIKey: "configured" }, + }, + }, + }); + } + if (method === "DetectDiscType") return ""; + if (method === "FetchMetadata") { + return metadataPreview(String((body as { Path?: string }).Path || "")); + } + throw new Error(`unexpected app request: ${method}`); + }); + + render(createElement(App)); + const sourceInput = screen.getByLabelText("Source path"); + fireEvent.change(sourceInput, { target: { value: "C:\\media\\Example.Release.2026.mkv" } }); + fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); + + expect(await screen.findByText("2/2")).toBeInTheDocument(); + const aither = screen.getByRole("checkbox", { name: "AITHER" }); + expect(aither).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "BLU" })).toBeChecked(); + + fireEvent.click(aither); + expect(aither).not.toBeChecked(); + + fireEvent.change(sourceInput, { target: { value: "C:\\media\\Next.Release.2026.mkv" } }); + fireEvent.click(screen.getByRole("button", { name: "Fetch metadata" })); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "AITHER" })).toBeChecked()); + }); +}); diff --git a/webui/src/app.tsx b/webui/src/app.tsx new file mode 100644 index 00000000..7305cecd --- /dev/null +++ b/webui/src/app.tsx @@ -0,0 +1,746 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { configClient, hostBrowser as hostBrowserClient } from "./api/app"; +import { isHostPathCaseInsensitive } from "./api/client"; +import BlurayCandidatesPage from "./pages/bluray_candidates"; +import DescriptionBuilderPage from "./pages/description_builder"; +import DupeCheckPage from "./pages/dupe_check"; +import HistoryPage from "./pages/history"; +import InputPage from "./pages/input"; +import LoggingPage from "./pages/logging"; +import MenuImagesPage from "./pages/menu_images"; +import ScreenshotsPage from "./pages/screenshots"; +import SettingsPage from "./pages/settings"; +import TrackerDataPage from "./pages/tracker_data"; +import TrackerUploadPage from "./pages/tracker_upload"; +import UploadImagesPage from "./pages/upload_images"; +import { useSettingsState } from "./hooks/useSettingsState"; +import { useTrackerIcons } from "./hooks/useTrackerIcons"; +import { JobRegistryProvider, useJobNotifications } from "./jobRegistry"; +import { ReleaseSessionProvider, useReleaseSession } from "./releaseSession"; +import { TrackerCatalogProvider } from "./trackerCatalog"; +import type { ReleaseRoute } from "./releaseSession/types"; +import type { BrowseDirectoryResponse, ConfigMap } from "./types"; +import { cn } from "./utils/cn"; +import { + addSourcePathHistoryEntry, + defaultInputHistoryLimit, + filterBrowseEntries, + inferSourcePathMode, + normalizeSourcePathHistory, + resolveInputHistoryLimit, + sourcePathHistoryStorageKey, + type SourcePathHistoryEntry, + type SourcePathMode, +} from "./utils/inputHistory"; +import { normalizeDefaultTrackerList } from "./utils/settings"; + +const appLayoutClass = + "relative z-[1] block min-h-screen ml-[204px] max-[960px]:ml-0 max-[960px]:pb-[78px]"; +const sidebarClass = + "fixed left-0 top-0 z-[1000] flex h-screen w-[204px] flex-col gap-2.5 border-r border-white/10 bg-[var(--panel)]/95 p-2.5 backdrop-blur max-[960px]:bottom-0 max-[960px]:top-auto max-[960px]:h-auto max-[960px]:w-full max-[960px]:flex-row max-[960px]:items-center max-[960px]:gap-2 max-[960px]:border-r-0 max-[960px]:border-t max-[960px]:p-2"; +const sidebarGroupClass = + "grid gap-1 rounded-lg border border-[rgba(148,163,184,0.18)] bg-[rgba(148,163,184,0.08)] p-1.5 max-[960px]:flex max-[960px]:flex-wrap max-[960px]:gap-1 max-[960px]:p-1"; +const navButtonClass = (active: boolean, nested = false) => + cn( + "w-full rounded-md border border-transparent bg-transparent px-2 py-1.5 text-left text-[0.84rem] font-semibold leading-tight text-[var(--muted)] transition hover:bg-white/10 hover:text-[var(--text)] disabled:cursor-not-allowed disabled:opacity-45 max-[960px]:w-auto", + nested && "pl-4 text-[0.8rem] font-medium max-[960px]:pl-2", + active && + "border-[var(--sidebar-active-border)] bg-[var(--sidebar-active-bg)] text-[var(--sidebar-active-text)]", + ); + +type ActiveTab = + | "input" + | "tracker" + | "bluray" + | "dupes" + | "screenshots" + | "menu_images" + | "upload_images" + | "description_builder" + | "upload" + | "history" + | "settings" + | "logging"; +type ThemeMode = "light" | "dark" | "auto"; +type ConfigOpStatus = { + type: "success" | "error" | "warning"; + title: string; + message: string; + warnings?: string[]; +} | null; + +const browserStorage = () => { + try { + return document.defaultView?.localStorage || null; + } catch { + return null; + } +}; + +/** Shell/router around the sole active release-session interface. */ +function AppShell() { + const releaseSession = useReleaseSession(); + const jobNotifications = useJobNotifications(); + const [activeTab, setActiveTab] = useState("input"); + const [navigationNotice, setNavigationNotice] = useState(""); + const [lightboxImage, setLightboxImage] = useState(""); + const [lightboxAlt, setLightboxAlt] = useState(""); + const [theme, setTheme] = useState( + () => (browserStorage()?.getItem("theme") as ThemeMode | null) || "auto", + ); + const [sourcePathHistory, setSourcePathHistory] = useState(() => { + try { + return normalizeSourcePathHistory( + JSON.parse(browserStorage()?.getItem(sourcePathHistoryStorageKey) || "[]"), + defaultInputHistoryLimit, + isHostPathCaseInsensitive(), + ); + } catch { + return []; + } + }); + const defaultTrackerSelectionSession = useRef(null); + const [hostBrowserMode, setHostBrowserMode] = useState(null); + const [hostBrowser, setHostBrowser] = useState(null); + const [hostBrowserLoading, setHostBrowserLoading] = useState(false); + const [hostBrowserError, setHostBrowserError] = useState(""); + const [hostBrowserSearch, setHostBrowserSearch] = useState(""); + const [settingsExporting, setSettingsExporting] = useState(false); + const [settingsImporting, setSettingsImporting] = useState(false); + const [importConfirmOpen, setImportConfirmOpen] = useState(false); + const [configOpStatus, setConfigOpStatus] = useState(null); + + const settings = useSettingsState({ activeTab }); + const { + configData, + settingsLoading, + settingsDirty, + settingsSaved, + settingsError, + settingsSection, + settingsSections, + showAdvancedToggle, + advancedOpen, + setSettingsSection, + setSettingsAdvanced, + loadSettings, + handleSaveSettings, + renderImageHostingSection, + renderTrackerSection, + renderTorrentClientsSection, + renderField, + sectionFieldMeta, + updateConfigValue, + updateScreenshotConfigValue, + configuredImageHosts, + screenshotConfig, + clearSettingsStatus, + resolveImageHostLabel, + trackerSelectionNames, + } = settings; + + const inputHistoryLimit = useMemo(() => { + const main = (configData?.MainSettings || null) as ConfigMap | null; + return resolveInputHistoryLimit(main?.InputHistoryLimit); + }, [configData]); + const useFavicons = useMemo(() => { + const main = (configData?.MainSettings || null) as ConfigMap | null; + return typeof main?.UseFavicons === "boolean" ? main.UseFavicons : true; + }, [configData]); + const faviconOnly = useMemo(() => { + const main = (configData?.MainSettings || null) as ConfigMap | null; + return typeof main?.FaviconOnly === "boolean" ? main.FaviconOnly : false; + }, [configData]); + const trackerUploadItems = useMemo(() => { + const root = configData?.Trackers as ConfigMap | undefined; + const entriesRoot = root?.Trackers; + if (!entriesRoot || typeof entriesRoot !== "object" || Array.isArray(entriesRoot)) return []; + const visible = new Set(trackerSelectionNames); + return Object.entries(entriesRoot as ConfigMap) + .filter( + ([name, value]) => + visible.has(name) && value && typeof value === "object" && !Array.isArray(value), + ) + .map(([name, config]) => ({ name, config: config as ConfigMap })) + .sort((left, right) => left.name.localeCompare(right.name)); + }, [configData, trackerSelectionNames]); + const defaultTrackerNames = useMemo(() => { + const root = configData?.Trackers; + if (!root || typeof root !== "object" || Array.isArray(root)) return []; + return normalizeDefaultTrackerList((root as ConfigMap).DefaultTrackers ?? []); + }, [configData]); + const configuredDefaultTrackers = useMemo(() => { + const configuredNames = new Map( + trackerUploadItems.map((item) => [item.name.trim().toUpperCase(), item.name]), + ); + return Array.from( + new Set( + defaultTrackerNames + .map((name) => configuredNames.get(name.trim().toUpperCase()) || "") + .filter(Boolean), + ), + ); + }, [defaultTrackerNames, trackerUploadItems]); + const trackerIconSrcByName = useTrackerIcons(trackerUploadItems, useFavicons); + const maxMenuItems = useMemo(() => { + const value = screenshotConfig?.MaxMenuItems; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 6; + }, [screenshotConfig]); + + const preview = releaseSession.identity.view.preview; + const sourcePath = releaseSession.identity.view.sourcePath; + const access = releaseSession.navigation.view.access; + const hasTrackerData = releaseSession.input.view.trackerData.length > 0; + const hasBlurayData = Boolean(preview?.Bluray); + const currentDiscType = /(^|[\\/])VIDEO_TS([\\/]|$)/i.test(sourcePath) + ? "DVD" + : /(^|[\\/])BDMV([\\/]|$)/i.test(sourcePath) + ? "BDMV" + : ""; + + const applyTheme = useCallback((value: ThemeMode) => { + const resolved = + value === "auto" + ? document.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : value; + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(resolved); + }, []); + useEffect(() => applyTheme(theme), [applyTheme, theme]); + + const persistHistory = useCallback((entries: SourcePathHistoryEntry[]) => { + setSourcePathHistory(entries); + try { + if (entries.length) + browserStorage()?.setItem(sourcePathHistoryStorageKey, JSON.stringify(entries)); + else browserStorage()?.removeItem(sourcePathHistoryStorageKey); + } catch { + // Local storage can be disabled by browser policy. + } + }, []); + const rememberSource = useCallback( + (path: string, mode: SourcePathMode) => { + setSourcePathHistory((previous) => { + const next = addSourcePathHistoryEntry( + previous, + path, + mode, + inputHistoryLimit, + isHostPathCaseInsensitive(), + ); + try { + if (next.length) + browserStorage()?.setItem(sourcePathHistoryStorageKey, JSON.stringify(next)); + else browserStorage()?.removeItem(sourcePathHistoryStorageKey); + } catch { + // Local storage can be disabled by browser policy. + } + return next; + }); + }, + [inputHistoryLimit], + ); + useEffect(() => { + const release = releaseSession.identity.view.release; + if (release?.SourcePath) + rememberSource(release.SourcePath, inferSourcePathMode(release.SourcePath)); + }, [releaseSession.identity.view.release, rememberSource]); + useEffect(() => { + const sessionRevision = releaseSession.identity.view.sessionRevision; + if ( + defaultTrackerSelectionSession.current === sessionRevision || + !configData || + (defaultTrackerNames.length > 0 && trackerUploadItems.length === 0) + ) { + return; + } + defaultTrackerSelectionSession.current = sessionRevision; + const current = new Set(releaseSession.input.view.selectedTrackers); + if ( + current.size !== configuredDefaultTrackers.length || + configuredDefaultTrackers.some((tracker) => !current.has(tracker.trim().toUpperCase())) + ) { + releaseSession.input.chooseTrackers(configuredDefaultTrackers); + } + }, [ + configData, + configuredDefaultTrackers, + defaultTrackerNames.length, + releaseSession.identity.view.sessionRevision, + releaseSession.input, + trackerUploadItems.length, + ]); + useEffect(() => { + persistHistory( + normalizeSourcePathHistory(sourcePathHistory, inputHistoryLimit, isHostPathCaseInsensitive()), + ); + // Re-normalize only when the configured limit changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputHistoryLimit]); + + const openReleaseTab = (tab: ActiveTab, route: ReleaseRoute) => { + const routeAccess = access[route]; + if (!routeAccess.available) { + setNavigationNotice(routeAccess.reason); + return; + } + setNavigationNotice(""); + setActiveTab(tab); + }; + + const loadHostDirectory = async (path: string, mode: SourcePathMode) => { + setHostBrowserLoading(true); + setHostBrowserError(""); + try { + setHostBrowser(await hostBrowserClient.list(path, mode)); + } catch (error) { + setHostBrowserError(error instanceof Error ? error.message : String(error)); + } finally { + setHostBrowserLoading(false); + } + }; + const openHostBrowser = (mode: SourcePathMode) => { + setHostBrowserMode(mode); + setHostBrowserSearch(""); + void loadHostDirectory(releaseSession.input.view.sourceDraft, mode); + }; + const selectHostPath = (path: string, isDir: boolean) => { + if (!hostBrowserMode) return; + if ((hostBrowserMode === "folder" && !isDir) || (hostBrowserMode === "file" && isDir)) { + if (isDir) void loadHostDirectory(path, hostBrowserMode); + return; + } + releaseSession.input.updateSourceDraft(path); + releaseSession.input.selectSource(path); + rememberSource(path, hostBrowserMode); + setHostBrowserMode(null); + setActiveTab("input"); + }; + const visibleHostEntries = useMemo( + () => filterBrowseEntries(hostBrowser?.entries || [], hostBrowserSearch), + [hostBrowser?.entries, hostBrowserSearch], + ); + + const handleExportSettings = async () => { + clearSettingsStatus(); + setConfigOpStatus(null); + setSettingsExporting(true); + try { + const file = await configClient.exportDownload(); + setConfigOpStatus({ type: "success", title: "Configuration exported", message: file }); + } catch (error) { + setConfigOpStatus({ type: "error", title: "Export failed", message: String(error) }); + } finally { + setSettingsExporting(false); + } + }; + const handleImportConfigConfirm = async () => { + setSettingsImporting(true); + try { + const result = await configClient.importFile(); + if (result.message) { + setConfigOpStatus({ + type: result.warnings.length ? "warning" : "success", + title: result.warnings.length ? "Imported with warnings" : "Configuration imported", + message: result.message, + warnings: result.warnings, + }); + loadSettings(); + } + } catch (error) { + setConfigOpStatus({ type: "error", title: "Import failed", message: String(error) }); + } finally { + setSettingsImporting(false); + setImportConfirmOpen(false); + } + }; + + return ( +
+
+
+
+ + +
+ {navigationNotice ? ( +

+ {navigationNotice} +

+ ) : null} + {activeTab === "settings" ? ( + setConfigOpStatus(null)} + settingsSection={settingsSection} + settingsSections={settingsSections} + trackerSelectionNames={trackerSelectionNames} + showAdvancedToggle={showAdvancedToggle} + advancedOpen={advancedOpen} + setSettingsSection={setSettingsSection} + setSettingsAdvanced={setSettingsAdvanced} + loadSettings={loadSettings} + handleExportSettings={() => void handleExportSettings()} + handleImportConfig={() => setImportConfirmOpen(true)} + importConfirmOpen={importConfirmOpen} + handleImportConfigConfirm={handleImportConfigConfirm} + handleImportConfigCancel={() => !settingsImporting && setImportConfirmOpen(false)} + handleSaveSettings={handleSaveSettings} + renderImageHostingSection={renderImageHostingSection} + renderTrackerSection={renderTrackerSection} + renderTorrentClientsSection={renderTorrentClientsSection} + renderField={renderField} + sectionFieldMeta={sectionFieldMeta} + /> + ) : activeTab === "logging" ? ( + + ) : activeTab === "history" ? ( + { + if (deletedPath === sourcePath) releaseSession.input.selectSource(""); + }} + /> + ) : activeTab === "dupes" ? ( + + ) : activeTab === "screenshots" ? ( + void handleSaveSettings()} + setLightboxImage={setLightboxImage} + setLightboxAlt={setLightboxAlt} + /> + ) : activeTab === "menu_images" ? ( + openReleaseTab("upload_images", "uploadedImages")} + setLightboxImage={setLightboxImage} + setLightboxAlt={setLightboxAlt} + /> + ) : activeTab === "upload_images" ? ( + + ) : activeTab === "bluray" ? ( + + ) : activeTab === "description_builder" ? ( + + ) : activeTab === "upload" ? ( + + ) : activeTab === "tracker" ? ( + + ) : ( + openHostBrowser("file")} + handleBrowseFolder={() => openHostBrowser("folder")} + trackerUploadItems={trackerUploadItems} + showExternalIDInputUI + setLightboxImage={setLightboxImage} + setLightboxAlt={setLightboxAlt} + useFavicons={useFavicons} + faviconOnly={faviconOnly} + trackerIconSrcByName={trackerIconSrcByName} + /> + )} +
+ + { + if (!open) { + setLightboxImage(""); + setLightboxAlt(""); + } + }} + > + + + + {lightboxAlt || "Image preview"} + + Expanded release image preview. + + {lightboxAlt} + + Close + + + + + + { + if (!open) setHostBrowserMode(null); + }} + > + + + + Select {hostBrowserMode === "file" ? "file" : "folder"} + + Browse host paths allowed by WebUI policy. + +
+ + setHostBrowserSearch(event.target.value)} + placeholder="Filter paths" + /> + {hostBrowserMode === "folder" && hostBrowser?.currentPath ? ( + + ) : null} +
+ {hostBrowserError ? ( +

+ {hostBrowserError} +

+ ) : null} +
+ {hostBrowserLoading ? ( +

Loading...

+ ) : ( + visibleHostEntries.map((entry) => ( + + )) + )} +
+ Cancel +
+
+
+
+
+ ); +} + +/** Composes owner Job registry and active release session around the shell. */ +export default function App({ jobOwnerKey = "standalone" }: Readonly<{ jobOwnerKey?: string }>) { + return ( + + + + + + + + ); +} diff --git a/webui/src/components/LogSettingsPanel.test.tsx b/webui/src/components/LogSettingsPanel.test.tsx new file mode 100644 index 00000000..7d2606f5 --- /dev/null +++ b/webui/src/components/LogSettingsPanel.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { screen, waitFor } from "@testing-library/dom"; +import { cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import LogSettingsPanel from "./LogSettingsPanel"; +import { loggingClient } from "../api/app"; +import { subscribeWebEvent } from "../api/client"; + +vi.mock("../api/app", () => ({ + loggingClient: { + getPath: vi.fn(), + getRecent: vi.fn(), + getExclusions: vi.fn(), + startStream: vi.fn(), + stopStream: vi.fn(), + updateExclusions: vi.fn(), + }, +})); + +vi.mock("../api/client", () => ({ + subscribeWebEvent: vi.fn(() => vi.fn()), +})); + +const installLogAPI = () => { + vi.mocked(loggingClient.getPath).mockResolvedValue("C:/logs/upbrr.log"); + vi.mocked(loggingClient.getRecent).mockResolvedValue([]); + vi.mocked(loggingClient.getExclusions).mockResolvedValue([]); + vi.mocked(loggingClient.startStream).mockResolvedValue("stream-1"); + vi.mocked(loggingClient.stopStream).mockResolvedValue(undefined); + vi.mocked(loggingClient.updateExclusions).mockResolvedValue(undefined); +}; + +describe("LogSettingsPanel", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders log path and updates logging level", async () => { + installLogAPI(); + const updateConfigValue = vi.fn(); + + render( +
{label}
} + updateConfigValue={updateConfigValue} + />, + ); + + await waitFor(() => expect(screen.getByText("C:/logs/upbrr.log")).toBeInTheDocument()); + + await userEvent.selectOptions(screen.getByLabelText("Verbosity"), "debug"); + + expect(updateConfigValue).toHaveBeenCalledWith(["Logging", "Level"], "debug"); + }); + + it("keeps distinct rows when a restarted log stream reuses IDs", async () => { + installLogAPI(); + vi.mocked(loggingClient.getRecent).mockResolvedValueOnce([ + { + ID: 1, + Time: "2026-06-15T00:00:00.000Z", + Level: "info", + Message: "before restart", + }, + ]); + vi.mocked(loggingClient.getRecent).mockResolvedValueOnce([ + { + ID: 1, + Time: "2026-06-15T00:01:00.000Z", + Level: "info", + Message: "after restart", + }, + ]); + + render( +
{label}
} + updateConfigValue={vi.fn()} + />, + ); + + await waitFor(() => expect(screen.getByText("before restart")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("after restart")).toBeInTheDocument()); + }); + + it("dedupes exact recent/live overlap rows", async () => { + installLogAPI(); + const row = { + ID: 2, + Time: "2026-06-15T00:00:00.000Z", + Level: "info", + Message: "overlap", + }; + vi.mocked(loggingClient.getRecent).mockResolvedValue([row]); + vi.mocked(subscribeWebEvent).mockImplementationOnce((_eventName, callback) => { + callback(row); + return vi.fn(); + }); + + render( +
{label}
} + updateConfigValue={vi.fn()} + />, + ); + + await waitFor(() => expect(subscribeWebEvent).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByText("overlap")).toHaveLength(1)); + }); + + it("does not restart the log stream when auto-scroll changes", async () => { + installLogAPI(); + + render( +
{label}
} + updateConfigValue={vi.fn()} + />, + ); + + await waitFor(() => expect(loggingClient.startStream).toHaveBeenCalledTimes(1)); + + await userEvent.click(screen.getByLabelText("Auto-scroll logs")); + + expect(loggingClient.stopStream).not.toHaveBeenCalled(); + expect(loggingClient.startStream).toHaveBeenCalledTimes(1); + }); + + it("stops a started stream when event subscription fails", async () => { + installLogAPI(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.mocked(subscribeWebEvent).mockImplementationOnce(() => { + throw new Error("subscribe failed"); + }); + + render( +
{label}
} + updateConfigValue={vi.fn()} + />, + ); + + try { + await waitFor(() => expect(loggingClient.stopStream).toHaveBeenCalledWith("stream-1")); + expect(consoleError).toHaveBeenCalledWith("Failed to start log stream", expect.any(Error)); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/gui/frontend/src/components/LogSettingsPanel.tsx b/webui/src/components/LogSettingsPanel.tsx similarity index 95% rename from gui/frontend/src/components/LogSettingsPanel.tsx rename to webui/src/components/LogSettingsPanel.tsx index ed30b567..dad009a6 100644 --- a/gui/frontend/src/components/LogSettingsPanel.tsx +++ b/webui/src/components/LogSettingsPanel.tsx @@ -4,8 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Checkbox } from "./ui/checkbox"; import { Switch } from "./ui/switch"; +import { loggingClient } from "../api/app"; +import { subscribeWebEvent } from "../api/client"; import { cn } from "../utils/cn"; -import { EventsOn } from "../utils/runtime"; type LogEntry = { ID: number; @@ -120,8 +121,7 @@ export default function LogSettingsPanel({ }, [autoScroll]); const persistMuted = async (patterns: string[]) => { - const updater = globalThis.go?.guiapp?.App?.UpdateLogExclusions; - if (!updater) return; + const updater = loggingClient.updateExclusions; try { await updater(patterns); } catch (err) { @@ -157,8 +157,7 @@ export default function LogSettingsPanel({ /** Backfills buffered log entries only while the caller's effect is still active. */ const fetchRecentLogs = useCallback( async (isActive: () => boolean = () => true) => { - const getRecent = globalThis.go?.guiapp?.App?.GetRecentLogs; - if (!getRecent) return; + const getRecent = loggingClient.getRecent; try { const payload = await getRecent(LOG_SOFT_CAP); if (!isActive()) return; @@ -175,8 +174,7 @@ export default function LogSettingsPanel({ useEffect(() => { const fetchLogPath = async () => { - const getLogPath = globalThis.go?.guiapp?.App?.GetLogPath; - if (!getLogPath) return; + const getLogPath = loggingClient.getPath; try { const path = await getLogPath(); setLogPath(path); @@ -197,8 +195,7 @@ export default function LogSettingsPanel({ useEffect(() => { const fetchMuted = async () => { - const getter = globalThis.go?.guiapp?.App?.GetLogExclusions; - if (!getter) return; + const getter = loggingClient.getExclusions; try { const patterns = await getter(); if (Array.isArray(patterns)) { @@ -214,9 +211,8 @@ export default function LogSettingsPanel({ useEffect(() => { let active = true; const startStream = async () => { - const start = globalThis.go?.guiapp?.App?.StartLogStream; - const stop = globalThis.go?.guiapp?.App?.StopLogStream; - if (!start) return; + const start = loggingClient.startStream; + const stop = loggingClient.stopStream; // Tracks a stream that exists before event subscription cleanup is installed. let pendingStreamID = ""; @@ -225,27 +221,25 @@ export default function LogSettingsPanel({ pendingStreamID = streamID; if (!active) { pendingStreamID = ""; - if (stop) await stop(streamID); + await stop(streamID); return; } const eventName = `log:stream:${streamID}`; - const off = EventsOn(eventName, (payload: any) => { + const off = subscribeWebEvent(eventName, (payload: any) => { const entry = normalizeEntry(payload); if (entry) appendEntries([entry]); }); setConnected(true); streamStopRef.current = () => { off(); - if (stop) { - stop(streamID).catch(() => undefined); - } + stop(streamID).catch(() => undefined); }; pendingStreamID = ""; // Backfill after subscribing to cover entries emitted between // StartLogStream resolving and the event listener attaching. await fetchRecentLogs(() => active); } catch (err) { - if (pendingStreamID && stop) { + if (pendingStreamID) { await stop(pendingStreamID).catch(() => undefined); } setConnected(false); diff --git a/webui/src/components/RenderedDescription.test.tsx b/webui/src/components/RenderedDescription.test.tsx new file mode 100644 index 00000000..7f254edd --- /dev/null +++ b/webui/src/components/RenderedDescription.test.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import RenderedDescription from "./RenderedDescription"; + +describe("RenderedDescription external links", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("marks rendered links to open in a new tab", () => { + render(); + + const link = screen.getByRole("link", { name: "View image" }); + + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noreferrer"); + }); + + it("opens web UI links in a new tab", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + render(); + + fireEvent.click(screen.getByRole("link", { name: "Torrent" })); + + expect(open).toHaveBeenCalledWith( + "https://example.com/torrent", + "_blank", + "noopener,noreferrer", + ); + }); + + it("routes aux-clicks through the same external-link handling", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + render(); + + fireEvent( + screen.getByRole("link", { name: "Raw URL" }), + new MouseEvent("auxclick", { bubbles: true, button: 1 }), + ); + + expect(open).toHaveBeenCalledWith("https://example.com/raw", "_blank", "noopener,noreferrer"); + }); + + it("blocks relative links instead of opening them against the app origin", () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + render(); + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + const dispatched = screen.getByRole("link", { name: "Relative release" }).dispatchEvent(event); + + expect(dispatched).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/gui/frontend/src/components/RenderedDescription.tsx b/webui/src/components/RenderedDescription.tsx similarity index 100% rename from gui/frontend/src/components/RenderedDescription.tsx rename to webui/src/components/RenderedDescription.tsx diff --git a/gui/frontend/src/components/ui/badge.tsx b/webui/src/components/ui/badge.tsx similarity index 100% rename from gui/frontend/src/components/ui/badge.tsx rename to webui/src/components/ui/badge.tsx diff --git a/gui/frontend/src/components/ui/button.tsx b/webui/src/components/ui/button.tsx similarity index 100% rename from gui/frontend/src/components/ui/button.tsx rename to webui/src/components/ui/button.tsx diff --git a/gui/frontend/src/components/ui/checkbox.tsx b/webui/src/components/ui/checkbox.tsx similarity index 100% rename from gui/frontend/src/components/ui/checkbox.tsx rename to webui/src/components/ui/checkbox.tsx diff --git a/gui/frontend/src/components/ui/switch.tsx b/webui/src/components/ui/switch.tsx similarity index 100% rename from gui/frontend/src/components/ui/switch.tsx rename to webui/src/components/ui/switch.tsx diff --git a/gui/frontend/src/components/ui/tracker-icon.tsx b/webui/src/components/ui/tracker-icon.tsx similarity index 100% rename from gui/frontend/src/components/ui/tracker-icon.tsx rename to webui/src/components/ui/tracker-icon.tsx diff --git a/webui/src/display.contract.test.ts b/webui/src/display.contract.test.ts new file mode 100644 index 00000000..68bc30c0 --- /dev/null +++ b/webui/src/display.contract.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { describe, expect, it } from "vitest"; + +import fixture from "../../pkg/api/testdata/prepared_release_display.json"; +import type { PreparedReleaseDisplay } from "./types"; + +describe("prepared release display contract", () => { + it("consumes the canonical five-provider fixture as an exhaustive typed union", () => { + const display = fixture as PreparedReleaseDisplay; + expect(display.Providers.map((provider) => provider.Provider)).toEqual([ + "tmdb", + "imdb", + "tvdb", + "tvmaze", + "mal", + ]); + for (const provider of display.Providers) { + switch (provider.Provider) { + case "tmdb": + expect(provider.Details.TMDB.TMDBID).toBe(provider.ID); + break; + case "imdb": + expect(provider.Details.IMDB.IMDBID).toBe(provider.ID); + break; + case "tvdb": + expect(provider.Details.TVDB.TVDBID).toBe(provider.ID); + break; + case "tvmaze": + expect(provider.Details.TVmaze.TVmazeID).toBe(provider.ID); + break; + case "mal": + expect(provider.Details.AniList.MALID).toBe(provider.ID); + break; + } + } + }); +}); diff --git a/webui/src/hooks/useSettingsState.test.ts b/webui/src/hooks/useSettingsState.test.ts new file mode 100644 index 00000000..d78febb0 --- /dev/null +++ b/webui/src/hooks/useSettingsState.test.ts @@ -0,0 +1,1467 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { createElement } from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { installAppOperationMocks } from "../test/appRequestMock"; +import type { ConfigValue, TrackerCatalog, TrackerCatalogEntry } from "../types"; + +import { + nextQbitDirectState, + normalizeTorrentClientForSave, + normalizeTorrentClientsForSave, + useSettingsState, +} from "./useSettingsState"; + +afterEach(() => { + cleanup(); + latestPayload = ""; +}); + +describe("normalizeTorrentClientForSave", () => { + it("preserves watch client fields", () => { + expect( + normalizeTorrentClientForSave({ + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }), + ).toEqual({ + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }); + }); + + it("migrates legacy qbit fields and removes aliases", () => { + expect( + normalizeTorrentClientForSave({ + TorrentClient: "qbit", + URL: "http://localhost:8080", + Username: "user", + Password: "secret", + Category: "movies", + Tags: ["AITHER", "BLU"], + }), + ).toEqual({ + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + QbitCategoryValue: "movies", + QbitTag: "AITHER,BLU", + }); + }); + + it("maps legacy TLS skip verify to certificate verification before removing aliases", () => { + expect( + normalizeTorrentClientForSave({ + TorrentClient: "qbit", + TLSSkipVerify: true, + }), + ).toEqual({ + VerifyWebUICertificate: false, + }); + }); +}); + +describe("normalizeTorrentClientsForSave", () => { + it("normalizes each configured client without dropping watch-folder config", () => { + expect( + normalizeTorrentClientsForSave({ + TorrentClients: { + qbit: { + Type: "qbit", + URL: "http://localhost:8080", + Username: "user", + Password: "secret", + }, + watch: { + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }, + }, + }), + ).toEqual({ + TorrentClients: { + qbit: { + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + }, + watch: { + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }, + }, + }); + }); +}); + +describe("nextQbitDirectState", () => { + it("clears proxy and direct credentials when qbit direct is disabled", () => { + expect( + nextQbitDirectState( + { + QuiProxyURL: "http://proxy.local", + QbitURL: "http://localhost:8080", + QbitPort: 8080, + QbitUser: "user", + QbitPass: "secret", + URL: "http://legacy.local", + Username: "legacy-user", + Password: "legacy-pass", + }, + false, + ), + ).toEqual({ + QuiProxyURL: "", + QbitURL: "", + QbitPort: 0, + QbitUser: "", + QbitPass: "", + URL: "", + Username: "", + Password: "", + }); + }); +}); + +function TorrentClientsHarness() { + const state = useSettingsState({ activeTab: "settings" }); + + return createElement( + "div", + null, + state.renderTorrentClientsSection(false), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +function ClientSetupHarness() { + const state = useSettingsState({ activeTab: "settings" }); + const clientSetup = state.configData?.ClientSetup; + + if (!clientSetup || typeof clientSetup !== "object" || Array.isArray(clientSetup)) { + return createElement("div", null); + } + + const meta = state.sectionFieldMeta.ClientSetup ?? {}; + + return createElement( + "div", + null, + ...Object.entries(clientSetup).map(([key, value]) => + state.renderField(key, value, ["ClientSetup", key], meta[key]), + ), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +function TrackerSettingsHarness() { + const state = useSettingsState({ activeTab: "settings" }); + + return createElement( + "div", + null, + state.renderTrackerSection(false), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +function TrackerSettingsAdvancedHarness() { + const state = useSettingsState({ activeTab: "settings" }); + + return createElement( + "div", + null, + state.renderTrackerSection(true), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +function TrackerSettingsErrorHarness() { + const state = useSettingsState({ activeTab: "settings" }); + return createElement("div", null, state.settingsError); +} + +function InputTrackerSelectionHarness() { + const state = useSettingsState({ activeTab: "input" }); + return createElement( + "div", + { "data-testid": "tracker-selection" }, + state.trackerSelectionNames.join(","), + ); +} + +function ImageHostingHarness() { + const state = useSettingsState({ activeTab: "settings" }); + + return createElement( + "div", + null, + state.renderImageHostingSection(), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +function ScreenshotSettingsHarness() { + const state = useSettingsState({ activeTab: "settings" }); + const config = state.screenshotConfig; + if (!config) { + return createElement("div"); + } + return createElement( + "div", + null, + state.renderField( + "MaxMenuItems", + config.MaxMenuItems, + ["ScreenshotHandling", "MaxMenuItems"], + state.sectionFieldMeta.ScreenshotHandling?.MaxMenuItems, + ), + createElement(PayloadCapture, { value: state.buildSavePayload() }), + ); +} + +let latestPayload = ""; + +type TestTrackerField = [key: string, defaultValue: ConfigValue, activation?: boolean]; + +/** Builds a typed tracker-catalog fixture without tracker-name logic in the hook. */ +function trackerCatalogEntry( + name: string, + fields: TestTrackerField[], + configured = false, + family: TrackerCatalogEntry["family"] = "unit3d", +): TrackerCatalogEntry { + return { + name, + family, + baseURL: `https://${name.toLowerCase()}.example.invalid`, + uploadContentMode: "description", + configured, + fields: fields.map(([key, defaultValue, activation = false]) => ({ + key, + yamlKey: key, + default: defaultValue, + activation, + })), + }; +} + +/** Wraps catalog entries in the backend response shape. */ +function trackerCatalog(...entries: TrackerCatalogEntry[]): TrackerCatalog { + return { entries, unsupported: [] }; +} + +/** Captures save payloads without rendering secret-shaped values into DOM snapshots. */ +function PayloadCapture({ value }: { value: string | null }) { + latestPayload = value ?? ""; + return null; +} + +/** Parses the latest captured payload for focused assertions outside matcher output. */ +function readPayload() { + return JSON.parse(latestPayload || "{}") as T; +} + +function AdvancedFieldMetaHarness() { + const state = useSettingsState({ activeTab: "settings" }); + const advancedBySection = Object.fromEntries( + Object.entries(state.sectionFieldMeta).map(([section, fields]) => [ + section, + Object.values(fields) + .filter((field) => field.advanced) + .map((field) => field.key) + .sort(), + ]), + ); + + return createElement( + "pre", + { "data-testid": "advanced-fields" }, + JSON.stringify(advancedBySection), + ); +} + +describe("settings advanced fields", () => { + it("matches the configured per-section advanced allowlist", () => { + render(createElement(AdvancedFieldMetaHarness)); + + const advancedBySection = JSON.parse( + screen.getByTestId("advanced-fields").textContent ?? "{}", + ) as Record; + + expect(advancedBySection.MainSettings).toEqual([]); + expect(advancedBySection.Metadata).toEqual([ + "BTNAPI", + "BlurayScore", + "BluraySingleScore", + "CheckPredb", + "SkipAutoTorrent", + "SkipTrackerFilenameLookup", + "UserOverrides", + ]); + expect(advancedBySection.ScreenshotHandling).toEqual([ + "Desat", + "FFmpegCompression", + "FFmpegLimit", + "MaxConcurrentUploads", + "ProcessLimit", + "TonemapAlgorithm", + ]); + expect(advancedBySection.Description).toEqual([ + "CharLimit", + "CustomSignature", + "FileLimit", + "LogoLanguage", + "LogoSize", + "ProcessLimit", + ]); + expect(advancedBySection.PostUpload).toEqual(["InjectDelay", "MaxConcurrentTrackers"]); + expect(advancedBySection.TorrentCreation).toEqual([]); + expect(advancedBySection.TorrentClients).toEqual(["VerifyWebUICertificate"]); + }); +}); + +describe("DVD menu screenshot settings", () => { + it("loads the default maximum and preserves edits in the save payload", async () => { + installAppOperationMocks({ + GetConfig: async () => JSON.stringify({ ScreenshotHandling: { MaxMenuItems: 6 } }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ScreenshotSettingsHarness)); + const input = await screen.findByLabelText("Maximum DVD menu images"); + expect(input).toHaveValue(6); + fireEvent.change(input, { target: { value: "8" } }); + await waitFor(() => { + const payload = readPayload<{ ScreenshotHandling?: { MaxMenuItems?: number } }>(); + expect(payload.ScreenshotHandling?.MaxMenuItems).toBe(8); + }); + }); +}); + +describe("renderTorrentClientsSection", () => { + it("renders watch client fields and preserves qbit clients on update", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + TorrentClients: { + watcher: { + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }, + qbit: { + Type: "qbit", + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + AutomaticManagementPaths: ["/media"], + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TorrentClientsHarness)); + + await waitFor(() => expect(screen.getByText("watcher")).toBeInTheDocument()); + + const watchCard = screen.getByText("watcher").closest(".settings-card"); + const qbitCard = screen.getByText("qbit").closest(".settings-card"); + expect(watchCard).toBeTruthy(); + expect(qbitCard).toBeTruthy(); + + const watchScope = within(watchCard as HTMLElement); + const qbitScope = within(qbitCard as HTMLElement); + + expect(watchScope.getByLabelText("Type")).toHaveValue("watch"); + expect(watchScope.getByLabelText("Watch folder")).toHaveValue("/watch"); + expect(watchScope.getByLabelText("Storage directory")).toHaveValue("/storage"); + expect(qbitScope.getByLabelText("qBit URL")).toHaveValue("http://localhost:8080"); + expect(qbitScope.getByLabelText("Automatic management paths 1")).toHaveValue("/media"); + expect(qbitScope.getByRole("button", { name: "Add Linked folder item" })).toBeInTheDocument(); + expect(qbitScope.getByRole("button", { name: "Add Local path item" })).toBeInTheDocument(); + expect(qbitScope.getByRole("button", { name: "Add Remote path item" })).toBeInTheDocument(); + expect( + qbitScope.getByRole("button", { name: "Add Automatic management paths item" }), + ).toBeInTheDocument(); + expect(qbitScope.getByLabelText("qBit direct")).toBeChecked(); + + fireEvent.change(watchScope.getByLabelText("Watch folder"), { + target: { value: "/watch/new" }, + }); + + await waitFor(() => + expect(watchScope.getByLabelText("Watch folder")).toHaveValue("/watch/new"), + ); + + const payload = readPayload<{ + TorrentClients?: Record>; + }>(); + expect(payload.TorrentClients?.watcher).toEqual({ + Type: "watch", + WatchFolder: "/watch/new", + StorageDir: "/storage", + }); + expect(payload.TorrentClients?.qbit).toMatchObject({ + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + AutomaticManagementPaths: ["/media"], + }); + }); +}); + +describe("ClientSetup client selectors", () => { + it("renders default client empty option without a none sentinel", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ClientSetup: { + DefaultClient: "", + }, + TorrentClients: { + qbit: { + Type: "qbit", + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ClientSetupHarness)); + + await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("")); + + const defaultClientSelect = screen.getByLabelText("Default client") as HTMLSelectElement; + expect(Array.from(defaultClientSelect.options).map((option) => option.value)).toEqual([ + "", + "qbit", + ]); + expect(Array.from(defaultClientSelect.options).map((option) => option.textContent)).toEqual([ + "", + "qbit", + ]); + + fireEvent.change(defaultClientSelect, { target: { value: "qbit" } }); + await waitFor(() => expect(defaultClientSelect).toHaveValue("qbit")); + + fireEvent.change(defaultClientSelect, { target: { value: "" } }); + await waitFor(() => expect(defaultClientSelect).toHaveValue("")); + + const payload = readPayload<{ + ClientSetup?: { DefaultClient?: string }; + }>(); + expect(payload.ClientSetup?.DefaultClient).toBe(""); + }); + + it("renders default, injected, and searching clients as torrent client dropdowns", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ClientSetup: { + DefaultClient: "qbit", + InjectClients: ["qbit"], + SearchClients: ["watcher"], + }, + TorrentClients: { + qbit: { + Type: "qbit", + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + }, + watcher: { + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ClientSetupHarness)); + + await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("qbit")); + + expect(screen.getByLabelText("Injected clients 1")).toHaveValue("qbit"); + expect(screen.getByLabelText("Searching clients 1")).toHaveValue("watcher"); + + fireEvent.change(screen.getByLabelText("Default client"), { + target: { value: "watcher" }, + }); + fireEvent.change(screen.getByLabelText("Injected clients 1"), { + target: { value: "watcher" }, + }); + + await waitFor(() => expect(screen.getByLabelText("Default client")).toHaveValue("watcher")); + + const payload = readPayload<{ + ClientSetup?: { + DefaultClient?: string; + InjectClients?: string[]; + SearchClients?: string[]; + }; + }>(); + expect(payload.ClientSetup?.DefaultClient).toBe("watcher"); + expect(payload.ClientSetup?.InjectClients).toEqual(["watcher"]); + expect(payload.ClientSetup?.SearchClients).toEqual(["watcher"]); + }); +}); + +describe("Tracker client selectors", () => { + it("renders CZT passkey field without preserving stale URL or API key", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + CZT: { + LinkDirName: "", + URL: "https://czteam.example", + APIKey: "service-token", + AnnounceURL: "https://czteam.me/announce.php?passkey=stale", + Passkey: "user-passkey", + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("CZT", [ + ["LinkDirName", ""], + ["Passkey", "", true], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("CZT", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("CZT", { selector: ".settings-card__summary-name" })); + + expect(screen.queryByLabelText("URL")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("API key")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Passkey")).toHaveValue("[REDACTED]"); + + const payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.CZT).toMatchObject({ + Passkey: "user-passkey", + }); + expect(payload.Trackers?.Trackers?.CZT?.URL).toBeUndefined(); + expect(payload.Trackers?.Trackers?.CZT?.APIKey).toBeUndefined(); + expect(payload.Trackers?.Trackers?.CZT?.AnnounceURL).toBeUndefined(); + }); + + it("creates CZT entries with passkey defaults", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: {}, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("CZT", [ + ["LinkDirName", ""], + ["Passkey", "", true], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0)); + + const trackerSelects = screen.getAllByRole("combobox"); + const trackerSelect = trackerSelects[trackerSelects.length - 1] as HTMLSelectElement; + fireEvent.change(trackerSelect, { target: { value: "CZT" } }); + fireEvent.click(screen.getByRole("button", { name: "Add entry" })); + + await waitFor(() => + expect( + screen.getByText("CZT", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + + const payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.CZT).toMatchObject({ + Passkey: "", + }); + expect(payload.Trackers?.Trackers?.CZT?.URL).toBeUndefined(); + expect(payload.Trackers?.Trackers?.CZT?.APIKey).toBeUndefined(); + }); + + it("renders tracker torrent client as a configured client dropdown", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + AITHER: { + LinkDirName: "", + APIKey: "tracker-token", + ImageHost: "", + TorrentClient: "qbit", + Anon: false, + }, + }, + }, + TorrentClients: { + qbit: { + Type: "qbit", + QbitURL: "http://localhost:8080", + QbitUser: "user", + QbitPass: "secret", + }, + watcher: { + Type: "watch", + WatchFolder: "/watch", + StorageDir: "/storage", + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("AITHER", [ + ["LinkDirName", ""], + ["APIKey", "", true], + ["ImageHost", ""], + ["TorrentClient", ""], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsAdvancedHarness)); + + await waitFor(() => + expect( + screen.getByText("AITHER", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("AITHER", { selector: ".settings-card__summary-name" })); + + await waitFor(() => expect(screen.getByLabelText("Torrent client")).toHaveValue("qbit")); + + const torrentClientSelect = screen.getByLabelText("Torrent client") as HTMLSelectElement; + expect(Array.from(torrentClientSelect.options).map((option) => option.textContent)).toEqual([ + "", + "qbit", + "watcher", + ]); + + fireEvent.change(screen.getByLabelText("Torrent client"), { + target: { value: "watcher" }, + }); + + await waitFor(() => expect(screen.getByLabelText("Torrent client")).toHaveValue("watcher")); + + const payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.AITHER?.TorrentClient).toBe("watcher"); + }); + + it("does not treat catalog tracker entries or default tracker membership as enabled config", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: ["AITHER", "BLU", "BHD"], + PreferredTracker: "", + Trackers: { + AITHER: { + APIKey: "", + Anon: false, + }, + BLU: { + APIKey: "", + Anon: false, + }, + BHD: { + APIKey: "tracker-token", + Anon: false, + }, + }, + }, + }), + GetDefaultConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: ["AITHER", "BLU", "BHD"], + PreferredTracker: "", + Trackers: { + AITHER: { + APIKey: "", + Anon: false, + }, + BLU: { + APIKey: "", + Anon: false, + }, + BHD: { + APIKey: "", + Anon: false, + }, + }, + }, + }), + ListTrackerCatalog: async () => + trackerCatalog( + ...["AITHER", "BLU", "BHD"].map((name) => + trackerCatalogEntry(name, [ + ["APIKey", "", true], + ["Anon", false], + ]), + ), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("BHD", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + + expect(screen.queryByText("AITHER", { selector: ".settings-card__summary-name" })).toBeNull(); + expect(screen.queryByText("BLU", { selector: ".settings-card__summary-name" })).toBeNull(); + expect(screen.getByText("1/1")).toBeInTheDocument(); + }); + + it("masks encrypted tracker credentials and preserves them for saves", async () => { + const encryptedAPIKey = "upbrr-enc:v1:encrypted-btn-api-key"; + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + BTN: { + APIKey: encryptedAPIKey, + Username: "", + Password: "", + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry( + "BTN", + [ + ["APIKey", "", true], + ["Username", "", true], + ["Password", "", true], + ], + true, + "standalone", + ), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("BTN", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("BTN", { selector: ".settings-card__summary-name" })); + + expect(screen.getByLabelText("API key")).toHaveValue("[REDACTED]"); + + let payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.BTN?.APIKey === encryptedAPIKey).toBe(true); + + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "replacement-api-key" }, + }); + + await waitFor(() => + expect(screen.getByLabelText("API key")).toHaveValue("replacement-api-key"), + ); + + payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.BTN?.APIKey).toBe("replacement-api-key"); + }); + + it("renders BTN announce URL from tracker schema when stored config lacks the key", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + BTN: { + APIKey: "tracker-token", + Username: "", + Password: "", + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry( + "BTN", + [ + ["APIKey", "", true], + ["Username", "", true], + ["Password", "", true], + ["AnnounceURL", "", true], + ], + false, + "standalone", + ), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("BTN", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("BTN", { selector: ".settings-card__summary-name" })); + + expect(screen.getByLabelText("Announce URL")).toHaveValue(""); + }); + + it("shows Lostimg as an LST image host only when configured in image hosting", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + LostimgEnabled: true, + LostimgAPI: "secret", + }, + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + LST: { + LinkDirName: "", + APIKey: "tracker-token", + ImageHost: "", + Anon: false, + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("LST", [ + ["LinkDirName", ""], + ["APIKey", "", true], + ["ImageHost", ""], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({ + TrackerUploadHosts: { LST: ["lostimg"] }, + OwnedHosts: { lostimg: "LST" }, + }), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("LST", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("LST", { selector: ".settings-card__summary-name" })); + + const imageHostSelect = screen.getByLabelText("Image host") as HTMLSelectElement; + expect(Array.from(imageHostSelect.options).map((option) => option.value)).toContain("lostimg"); + }); + + it("shows configured global hosts for LST when Lostimg is disabled", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "imgbb", + LostimgEnabled: false, + LostimgAPI: "", + }, + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + LST: { + LinkDirName: "", + APIKey: "tracker-token", + ImageHost: "", + Anon: false, + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("LST", [ + ["LinkDirName", ""], + ["APIKey", "", true], + ["ImageHost", ""], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({ + TrackerUploadHosts: { LST: ["lostimg"] }, + OwnedHosts: { lostimg: "LST" }, + }), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("LST", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("LST", { selector: ".settings-card__summary-name" })); + + const values = Array.from( + (screen.getByLabelText("Image host") as HTMLSelectElement).options, + ).map((option) => option.value); + expect(values).toContain("imgbb"); + expect(values).not.toContain("lostimg"); + }); + + it("shows Reelflix as an RF image host and exposes RF image API", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: {}, + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + RF: { + LinkDirName: "", + APIKey: "tracker-token", + ImgAPI: "", + ImageHost: "reelflix", + Anon: false, + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("RF", [ + ["LinkDirName", ""], + ["APIKey", "", true], + ["ImgAPI", ""], + ["ImageHost", ""], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({ + TrackerUploadHosts: { RF: ["reelflix"] }, + OwnedHosts: { reelflix: "RF" }, + }), + }); + + render(createElement(TrackerSettingsAdvancedHarness)); + + await waitFor(() => + expect( + screen.getByText("RF", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("RF", { selector: ".settings-card__summary-name" })); + + const imageHostSelect = screen.getByLabelText("Image host") as HTMLSelectElement; + expect(Array.from(imageHostSelect.options).map((option) => option.value)).toContain("reelflix"); + + fireEvent.change(screen.getByLabelText("Image API"), { + target: { value: "secret" }, + }); + + await waitFor(() => expect(screen.getByLabelText("Image API")).toHaveValue("secret")); + + const payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.RF?.ImgAPI).toBe("secret"); + }); + + it("shows configured global hosts for RF when Reelflix is disabled", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "imgbb", + }, + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + RF: { + LinkDirName: "", + APIKey: "tracker-token", + ImgAPI: "", + ImageHost: "", + Anon: false, + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("RF", [ + ["LinkDirName", ""], + ["APIKey", "", true], + ["ImgAPI", ""], + ["ImageHost", ""], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({ + TrackerUploadHosts: { RF: ["reelflix"] }, + OwnedHosts: { reelflix: "RF" }, + }), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("RF", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("RF", { selector: ".settings-card__summary-name" })); + + const values = Array.from( + (screen.getByLabelText("Image host") as HTMLSelectElement).options, + ).map((option) => option.value); + expect(values).toContain("imgbb"); + expect(values).not.toContain("reelflix"); + }); +}); + +describe("tracker catalog loading", () => { + it("loads configured tracker selections outside the settings page", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { Trackers: { BTN: { APIKey: "configured" } } }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog(trackerCatalogEntry("BTN", [["APIKey", "", true]], true, "standalone")), + }); + + render(createElement(InputTrackerSelectionHarness)); + + expect(await screen.findByTestId("tracker-selection")).toHaveTextContent("BTN"); + }); +}); + +describe("tracker catalog interactions", () => { + it("renders configured RHD without a frontend tracker schema entry", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { RHD: { APIKey: "tracker-token", Anon: false } }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("RHD", [ + ["APIKey", "", true], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + expect( + await screen.findByText("RHD", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(); + }); + + it("adds a synthetic Unit3D tracker and preserves catalog field order", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { DefaultTrackers: [], PreferredTracker: "", Trackers: {} }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("EXAMPLE", [ + ["UploaderName", ""], + ["APIKey", "", true], + ["ImageHost", ""], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + const trackerSelect = await screen.findByDisplayValue("Select tracker"); + fireEvent.change(trackerSelect, { target: { value: "EXAMPLE" } }); + fireEvent.click(screen.getByRole("button", { name: "Add entry" })); + + const card = ( + await screen.findByText("EXAMPLE", { selector: ".settings-card__summary-name" }) + ).closest(".settings-card"); + expect(card).toBeTruthy(); + const labels = Array.from( + (card as HTMLElement).querySelectorAll("label.settings-field > span"), + ).map((label) => label.textContent); + expect(labels).toEqual(["Uploader name", "API key", "Image host"]); + }); + + it("shows a tracker as configured when only one required credential is present", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { BTN: { Username: "user", Password: "" } }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry( + "BTN", + [ + ["Username", "", true], + ["Password", "", true], + ], + true, + "standalone", + ), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + expect( + await screen.findByText("BTN", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(); + }); + + it("removes an entry by resetting defaults and returning it to the selector", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: ["AITHER"], + PreferredTracker: "AITHER", + Trackers: { AITHER: { APIKey: "tracker-token", Anon: true } }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry("AITHER", [ + ["APIKey", "", true], + ["Anon", false], + ]), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + const cardName = await screen.findByText("AITHER", { + selector: ".settings-card__summary-name", + }); + const card = cardName.closest(".settings-card"); + fireEvent.click(within(card as HTMLElement).getByRole("button", { name: "Remove" })); + + await waitFor(() => + expect( + screen.queryByText("AITHER", { selector: ".settings-card__summary-name" }), + ).not.toBeInTheDocument(), + ); + const availableSelector = screen.getByDisplayValue("Select tracker"); + expect(within(availableSelector).getByRole("option", { name: "AITHER" })).toBeInTheDocument(); + const payload = readPayload<{ + Trackers?: { + DefaultTrackers?: string[]; + PreferredTracker?: string; + Trackers?: Record>; + }; + }>(); + expect(payload.Trackers?.DefaultTrackers).toEqual([]); + expect(payload.Trackers?.PreferredTracker).toBe(""); + expect(payload.Trackers?.Trackers?.AITHER).toEqual({ APIKey: "", Anon: false }); + }); + + it("separates unsupported entries and deletes them without making them selectable", async () => { + const catalog = trackerCatalog(trackerCatalogEntry("AITHER", [["APIKey", "", true]])); + catalog.unsupported = ["OLD"]; + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { OLD: { APIKey: "preserved" } }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => catalog, + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + const unsupported = await screen.findByText("Unsupported tracker entries"); + expect(unsupported).toBeInTheDocument(); + const oldCard = screen + .getByText("OLD", { selector: ".settings-card__summary-name" }) + .closest(".settings-card"); + expect(screen.queryByRole("option", { name: "OLD" })).not.toBeInTheDocument(); + fireEvent.click(within(oldCard as HTMLElement).getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect( + screen.queryByText("OLD", { selector: ".settings-card__summary-name" }), + ).not.toBeInTheDocument(), + ); + const payload = readPayload<{ + Trackers?: { Trackers?: Record> }; + }>(); + expect(payload.Trackers?.Trackers?.OLD).toBeUndefined(); + }); + + it("reports a stable error for an unknown catalog field", async () => { + installAppOperationMocks({ + GetConfig: async () => JSON.stringify({ Trackers: { Trackers: {} } }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog(trackerCatalogEntry("BROKEN", [["UnknownField", "", true]])), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsErrorHarness)); + + expect( + await screen.findByText(/Unsupported tracker config field: UnknownField/), + ).toBeInTheDocument(); + }); +}); + +describe("tracker advanced fields", () => { + it("hides only the tracker advanced allowlist when advanced is closed", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + Trackers: { + DefaultTrackers: [], + PreferredTracker: "", + Trackers: { + MTV: { + FaviconURL: "https://example.test/favicon.ico", + LinkDirName: "mtv", + APIKey: "api-key", + Username: "user", + Password: "pass", + AnnounceURL: "https://example.test/announce", + Anon: false, + OTPURI: "otpauth://totp/example", + SkipIfRehash: true, + PreferMTV: true, + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => + trackerCatalog( + trackerCatalogEntry( + "MTV", + [ + ["FaviconURL", ""], + ["LinkDirName", ""], + ["APIKey", "", true], + ["Username", "", true], + ["Password", "", true], + ["AnnounceURL", "", true], + ["Anon", false], + ["OTPURI", ""], + ["SkipIfRehash", false], + ["PreferMTV", false], + ], + false, + "standalone", + ), + ), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(TrackerSettingsHarness)); + + await waitFor(() => + expect( + screen.getByText("MTV", { selector: ".settings-card__summary-name" }), + ).toBeInTheDocument(), + ); + fireEvent.click(screen.getByText("MTV", { selector: ".settings-card__summary-name" })); + + expect(screen.queryByLabelText("Favicon URL")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Link dir name")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Skip if rehash")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Prefer MTV torrent")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Announce URL")).toBeInTheDocument(); + expect(screen.getByLabelText("OTP URI")).toBeInTheDocument(); + }); +}); + +describe("Image hosting settings", () => { + it("renders Lostimg config and keeps it out of global host priority", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "", + Host2: "", + Host3: "", + Host4: "", + Host5: "", + Host6: "", + LostimgEnabled: false, + LostimgAPI: "", + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ImageHostingHarness)); + + await waitFor(() => expect(screen.getByLabelText("Lostimg enabled")).toBeInTheDocument()); + + const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; + expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("lostimg"); + + fireEvent.click(screen.getByLabelText("Lostimg enabled")); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "secret" }, + }); + + await waitFor(() => expect(screen.getByLabelText("Lostimg enabled")).toBeChecked()); + + const payload = readPayload<{ + ImageHosting?: { + LostimgEnabled?: boolean; + LostimgAPI?: string; + }; + }>(); + expect(payload.ImageHosting?.LostimgEnabled).toBe(true); + expect(payload.ImageHosting?.LostimgAPI).toBe("secret"); + }); + + it("does not hard-code tracker-owned host fields into global image hosting", async () => { + installAppOperationMocks({ + GetConfig: async () => + JSON.stringify({ + ImageHosting: { + Host1: "", + Host2: "", + Host3: "", + Host4: "", + Host5: "", + Host6: "", + }, + Trackers: { + Trackers: { + RF: { + ImageHost: "", + ImgAPI: "", + }, + }, + }, + }), + GetDefaultConfig: async () => JSON.stringify({}), + ListTrackerCatalog: async () => trackerCatalog(), + GetImageHostPolicyMetadata: async () => ({}), + }); + + render(createElement(ImageHostingHarness)); + + await waitFor(() => expect(screen.getByLabelText("Host 1")).toBeInTheDocument()); + + const hostOne = screen.getByLabelText("Host 1") as HTMLSelectElement; + expect(Array.from(hostOne.options).map((option) => option.value)).not.toContain("reelflix"); + + expect(screen.queryByLabelText("RF Reelflix")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Image API")).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/hooks/useSettingsState.tsx b/webui/src/hooks/useSettingsState.tsx new file mode 100644 index 00000000..72404bf5 --- /dev/null +++ b/webui/src/hooks/useSettingsState.tsx @@ -0,0 +1,1954 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { configClient, trackerCatalogClient } from "../api/app"; +import { Button } from "../components/ui/button"; +import { PillCheckbox } from "../components/ui/checkbox"; +import { Switch } from "../components/ui/switch"; +import { trackerFieldPresentation } from "../settings/trackerFields"; +import type { + ConfigMap, + ConfigValue, + FieldMeta, + ImageHostPolicyMetadata, + TrackerCatalog, + TrackerCatalogEntry, +} from "../types"; +import { useTrackerCatalog } from "../trackerCatalog"; +import { formatLabel, normalizeDefaultTrackerList } from "../utils/settings"; + +type SettingsSection = { key: string; jsonKey: string; label: string }; + +const settingsInputClass = + "h-8 rounded-md border border-white/10 bg-slate-950/45 px-2.5 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--muted)] focus:border-[var(--accent-2)] focus:ring-2 focus:ring-[rgba(53,194,193,0.18)]"; + +const settingsSelectClass = `${settingsInputClass} cursor-pointer`; +type FieldOption = NonNullable[number]; + +type UseSettingsStateOptions = { + activeTab: string; +}; + +type UseSettingsStateResult = { + configData: ConfigMap | null; + settingsLoading: boolean; + settingsDirty: boolean; + settingsSaved: string; + settingsError: string; + settingsSection: string; + settingsSections: SettingsSection[]; + showAdvancedToggle: boolean; + advancedOpen: boolean; + setSettingsSection: Dispatch>; + setSettingsAdvanced: Dispatch>>; + loadSettings: () => void; + handleSaveSettings: () => void; + renderImageHostingSection: () => JSX.Element | null; + renderTrackerSection: (advancedOpen: boolean) => JSX.Element | null; + renderTorrentClientsSection: (advancedOpen: boolean) => JSX.Element | null; + renderMapSection: ( + sectionKey: string, + sectionValue: ConfigMap, + options?: { + entriesKey?: string; + defaultKey?: string; + fieldMeta?: Record; + advancedOpen?: boolean; + }, + ) => JSX.Element; + renderField: (label: string, value: ConfigValue, path: string[], meta?: FieldMeta) => JSX.Element; + sectionFieldMeta: Record>; + updateConfigValue: (path: string[], value: ConfigValue) => void; + updateScreenshotConfigValue: (key: string, value: ConfigValue) => void; + configuredImageHosts: string[]; + screenshotConfig: ConfigMap | null; + buildSavePayload: () => string | null; + clearSettingsStatus: () => void; + markSettingsSaved: (message: string) => void; + setSettingsSavedMessage: (message: string) => void; + setSettingsErrorMessage: (message: string) => void; + resolveImageHostLabel: (value: string) => string; + knownTrackersLoading: boolean; + trackerSelectionNames: string[]; +}; + +const settingsSections: SettingsSection[] = [ + { key: "main_settings", jsonKey: "MainSettings", label: "Main" }, + { key: "image_hosting", jsonKey: "ImageHosting", label: "Image Hosting" }, + { key: "metadata", jsonKey: "Metadata", label: "Metadata" }, + { key: "screenshot_handling", jsonKey: "ScreenshotHandling", label: "Screens" }, + { key: "description_settings", jsonKey: "Description", label: "Description" }, + { key: "arr_integration", jsonKey: "ArrIntegration", label: "Arr" }, + { key: "post_upload", jsonKey: "PostUpload", label: "Post Upload" }, + { key: "trackers", jsonKey: "Trackers", label: "Trackers" }, + { key: "torrent_clients", jsonKey: "TorrentClients", label: "Torrent Clients" }, + { key: "client_setup", jsonKey: "ClientSetup", label: "Client Handling" }, + { key: "torrent_creation", jsonKey: "TorrentCreation", label: "Torrent Specific" }, +]; + +const imageHostOptions = [ + { value: "", label: "None" }, + { value: "imgbb", label: "ImgBB" }, + { value: "imgbox", label: "ImgBox" }, + { value: "pixhost", label: "Pixhost" }, + { value: "lensdump", label: "Lensdump" }, + { value: "ptscreens", label: "PTScreens" }, + { value: "onlyimage", label: "OnlyImage" }, + { value: "dalexni", label: "Dalexni" }, + { value: "zipline", label: "Zipline" }, + { value: "passtheimage", label: "PassTheImage" }, + { value: "seedpool_cdn", label: "Seedpool CDN" }, + { value: "sharex", label: "ShareX" }, + { value: "utppm", label: "UTPPM" }, +]; + +const torrentClientTypeOptions = [ + { value: "qbit", label: "qBit" }, + { value: "watch", label: "Watch" }, +]; +const torrentClientLinkingOptions = [ + { value: "", label: "None" }, + { value: "hardlink", label: "Hardlink" }, + { value: "reflink", label: "Reflink" }, + { value: "symlink", label: "Symlink" }, +]; +const imageHostOptionLabels = new Map( + imageHostOptions.map((option) => [option.value, option.label]), +); +const normalizeImageHostValue = (value: string) => value.trim().toLowerCase(); +const imageHostOptionFor = (host: string) => { + const value = normalizeImageHostValue(host); + return { value, label: imageHostOptionLabels.get(value) ?? formatLabel(value) }; +}; + +const imageHostKeyMap: Record = { + imgbb: ["ImgBBAPI"], + lensdump: ["LensdumpAPI"], + ptscreens: ["PTScreensAPI"], + onlyimage: ["OnlyImageAPI"], + dalexni: ["DalexniAPI"], + zipline: ["ZiplineURL", "ZiplineAPIKey"], + passtheimage: ["PassTheImageAPI"], + seedpool_cdn: ["SeedpoolCDNAPI"], + sharex: ["ShareXURL", "ShareXAPIKey"], + utppm: ["UTPPMAPI"], +}; + +const stringField = (key: string, meta: Omit = {}): FieldMeta => ({ + key, + type: "string", + ...meta, +}); +const boolField = (key: string, meta: Omit = {}): FieldMeta => ({ + key, + type: "boolean", + ...meta, +}); +const numberField = (key: string, meta: Omit = {}): FieldMeta => ({ + key, + type: "number", + ...meta, +}); + +const REDACTED_VALUE = "[REDACTED]"; +const ENCRYPTED_SECRET_PREFIX = "upbrr-enc:v1:"; + +function hasConfiguredTrackerValue( + value: ConfigValue | undefined, + baseline: ConfigValue | undefined, +): boolean { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return baseline !== value; + } + if (typeof value === "number") { + return value !== 0 && baseline !== value; + } + if (typeof value === "boolean") { + return value && baseline !== value; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return false; + } + if (Array.isArray(baseline) && JSON.stringify(value) === JSON.stringify(baseline)) { + return false; + } + return true; + } + if (value && typeof value === "object") { + if (Object.keys(value).length === 0) { + return false; + } + if ( + baseline && + typeof baseline === "object" && + JSON.stringify(value) === JSON.stringify(baseline) + ) { + return false; + } + return true; + } + return false; +} + +const trackerConfigValue = (entries: ConfigMap, name: string): ConfigMap | null => { + const exact = entries[name]; + if (exact && typeof exact === "object" && !Array.isArray(exact)) return exact as ConfigMap; + const foldedName = Object.keys(entries).find( + (candidate) => candidate.trim().toLowerCase() === name.trim().toLowerCase(), + ); + if (!foldedName) return null; + const value = entries[foldedName]; + return value && typeof value === "object" && !Array.isArray(value) ? (value as ConfigMap) : null; +}; +const sensitiveKeyHints = [ + "password", + "passkey", + "token", + "api", + "key", + "secret", + "cookie", + "session", + "otp", + "announce_url", + "announceurl", +]; + +const sectionFieldMeta: Record> = { + ImageHosting: { + LostimgAPI: stringField("LostimgAPI", { label: "API key", sensitive: true }), + }, + MainSettings: { + InputHistoryLimit: { key: "InputHistoryLimit", label: "Input history limit", type: "number" }, + UseFavicons: { key: "UseFavicons", label: "Use favicons" }, + FaviconOnly: { key: "FaviconOnly", label: "Favicon only" }, + SceneDetection: { key: "SceneDetection", label: "Scene detection (srrdb)" }, + }, + Metadata: { + BTNAPI: { key: "BTNAPI", advanced: true, sensitive: true }, + SkipAutoTorrent: { key: "SkipAutoTorrent", advanced: true }, + SkipTrackerFilenameLookup: { key: "SkipTrackerFilenameLookup", advanced: true }, + UserOverrides: { key: "UserOverrides", advanced: true }, + BlurayScore: { key: "BlurayScore", advanced: true }, + BluraySingleScore: { key: "BluraySingleScore", advanced: true }, + CheckPredb: { key: "CheckPredb", advanced: true }, + }, + ScreenshotHandling: { + MaxMenuItems: { key: "MaxMenuItems", label: "Maximum DVD menu images", type: "number" }, + ProcessLimit: { key: "ProcessLimit", advanced: true }, + MaxConcurrentUploads: { key: "MaxConcurrentUploads", advanced: true }, + FFmpegLimit: { key: "FFmpegLimit", advanced: true }, + FFmpegCompression: { key: "FFmpegCompression", advanced: true }, + TonemapAlgorithm: { key: "TonemapAlgorithm", advanced: true }, + Desat: { key: "Desat", advanced: true }, + }, + Description: { + LogoSize: { key: "LogoSize", advanced: true }, + LogoLanguage: { key: "LogoLanguage", advanced: true }, + CharLimit: { key: "CharLimit", advanced: true }, + FileLimit: { key: "FileLimit", advanced: true }, + ProcessLimit: { key: "ProcessLimit", advanced: true }, + CustomSignature: { key: "CustomSignature", advanced: true }, + }, + ArrIntegration: { + SonarrURL1: { key: "SonarrURL1", advanced: true }, + SonarrAPIKey1: { key: "SonarrAPIKey1", advanced: true, sensitive: true }, + SonarrURL2: { key: "SonarrURL2", advanced: true }, + SonarrAPIKey2: { key: "SonarrAPIKey2", advanced: true, sensitive: true }, + SonarrURL3: { key: "SonarrURL3", advanced: true }, + SonarrAPIKey3: { key: "SonarrAPIKey3", advanced: true, sensitive: true }, + RadarrURL1: { key: "RadarrURL1", advanced: true }, + RadarrAPIKey1: { key: "RadarrAPIKey1", advanced: true, sensitive: true }, + RadarrURL2: { key: "RadarrURL2", advanced: true }, + RadarrAPIKey2: { key: "RadarrAPIKey2", advanced: true, sensitive: true }, + RadarrURL3: { key: "RadarrURL3", advanced: true }, + RadarrAPIKey3: { key: "RadarrAPIKey3", advanced: true, sensitive: true }, + EmbyDir: { key: "EmbyDir", advanced: true }, + EmbyTVDir: { key: "EmbyTVDir", advanced: true }, + }, + TorrentCreation: {}, + PostUpload: { + InjectDelay: { key: "InjectDelay", advanced: true }, + MaxConcurrentTrackers: { key: "MaxConcurrentTrackers", advanced: true }, + }, + Logging: {}, + TorrentClients: { + Type: stringField("Type", { label: "Type", options: torrentClientTypeOptions }), + WatchFolder: stringField("WatchFolder", { label: "Watch folder" }), + StorageDir: stringField("StorageDir", { label: "Storage directory" }), + QuiProxyURL: stringField("QuiProxyURL", { label: "Qui proxy URL", sensitive: true }), + QbitURL: stringField("QbitURL", { label: "qBit URL" }), + QbitPort: numberField("QbitPort", { label: "qBit port" }), + QbitUser: stringField("QbitUser", { label: "qBit user", sensitive: true }), + QbitPass: stringField("QbitPass", { label: "qBit pass", sensitive: true }), + QbitCategoryValue: stringField("QbitCategoryValue", { label: "qBit category" }), + QbitTag: stringField("QbitTag", { label: "qBit tag" }), + QbitCrossCategory: stringField("QbitCrossCategory", { label: "qBit cross category" }), + QbitCrossTag: stringField("QbitCrossTag", { label: "qBit cross tag" }), + UseTrackerAsTag: boolField("UseTrackerAsTag", { label: "Use tracker as tag" }), + Linking: stringField("Linking", { + label: "Linking", + options: torrentClientLinkingOptions, + }), + AllowFallback: boolField("AllowFallback", { label: "Allow link fallback" }), + LinkedFolder: stringField("LinkedFolder", { label: "Linked folder" }), + LocalPath: stringField("LocalPath", { label: "Local path" }), + RemotePath: stringField("RemotePath", { label: "Remote path" }), + AutomaticManagementPaths: stringField("AutomaticManagementPaths", { + label: "Automatic management paths", + }), + VerifyWebUICertificate: boolField("VerifyWebUICertificate", { + label: "Verify WebUI certificate", + advanced: true, + }), + }, +}; + +const isSensitiveKeyName = (key: string) => { + const lower = key.toLowerCase(); + return sensitiveKeyHints.some((hint) => lower.includes(hint)); +}; + +const buildPathKey = (path: string[]) => path.join("."); + +const isEncryptedSecretEnvelope = (value: string) => value.startsWith(ENCRYPTED_SECRET_PREFIX); + +/** Masks secret-bearing config strings while retaining originals by config path for save payloads. */ +const maskSensitiveConfig = (input: ConfigMap) => { + const originals: Record = {}; + const walk = (value: ConfigValue, path: string[]): ConfigValue => { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) { + return value.map((entry, index) => walk(entry, [...path, String(index)])); + } + if (typeof value === "object") { + const next: ConfigMap = {}; + Object.entries(value).forEach(([key, child]) => { + next[key] = walk(child, [...path, key]); + }); + return next; + } + if (typeof value === "string") { + const key = path[path.length - 1] || ""; + if (value && (isSensitiveKeyName(key) || isEncryptedSecretEnvelope(value))) { + originals[buildPathKey(path)] = value; + return REDACTED_VALUE; + } + return value; + } + return value; + }; + + return { masked: walk(input, []) as ConfigMap, originals }; +}; + +const restoreSensitiveConfig = (input: ConfigMap, originals: Record) => { + const walk = (value: ConfigValue, path: string[]): ConfigValue => { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) { + return value.map((entry, index) => walk(entry, [...path, String(index)])); + } + if (typeof value === "object") { + const next: ConfigMap = {}; + Object.entries(value).forEach(([key, child]) => { + next[key] = walk(child, [...path, key]); + }); + return next; + } + if (typeof value === "string") { + if (value === REDACTED_VALUE) { + const original = originals[buildPathKey(path)]; + if (original !== undefined) { + return original; + } + } + return value; + } + return value; + }; + + return walk(input, []) as ConfigMap; +}; + +const legacyTorrentClientKeys = [ + "Type", + "TorrentClient", + "URL", + "WatchFolder", + "StorageDir", + "Username", + "Password", + "Category", + "Tags", + "TLSSkipVerify", + "QbitTagsValue", +]; + +const qbitDefaultClient = (): ConfigMap => ({ + Type: "qbit", + QuiProxyURL: "", + QbitCategoryValue: "", + QbitTag: "", + QbitCrossCategory: "", + QbitCrossTag: "", + UseTrackerAsTag: false, + Linking: "", + AllowFallback: true, + LinkedFolder: [], + LocalPath: [], + RemotePath: [], + AutomaticManagementPaths: [], + VerifyWebUICertificate: true, +}); + +const qbitDirectDisabledValues: Readonly> = { + QbitURL: "", + QbitPort: 0, + QbitUser: "", + QbitPass: "", + URL: "", + Username: "", + Password: "", + QuiProxyURL: "", +}; + +const normalizeStringArray = (value: ConfigValue) => { + if (Array.isArray(value)) { + return value.map((entry) => String(entry ?? "").trim()).filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +}; + +const normalizeTorrentClientType = (client: ConfigMap) => { + const directType = typeof client.Type === "string" ? client.Type.trim() : ""; + if (directType) { + return directType.toLowerCase(); + } + const legacyType = typeof client.TorrentClient === "string" ? client.TorrentClient.trim() : ""; + if (legacyType) { + return legacyType.toLowerCase(); + } + return "qbit"; +}; + +/** + * Normalizes a torrent client before save, migrating legacy qBit fields to the + * canonical qBit keys while preserving non-qBit client configs. + */ +export const normalizeTorrentClientForSave = (client: ConfigMap) => { + const next = { ...client }; + if (normalizeTorrentClientType(next) !== "qbit") { + return next; + } + + if (!next.QbitURL && typeof next.URL === "string") next.QbitURL = next.URL; + if (!next.QbitUser && typeof next.Username === "string") next.QbitUser = next.Username; + if (!next.QbitPass && typeof next.Password === "string") next.QbitPass = next.Password; + if (!next.QbitCategoryValue && typeof next.Category === "string") { + next.QbitCategoryValue = next.Category; + } + if (!next.QbitTag) { + if (Array.isArray(next.Tags)) { + next.QbitTag = normalizeStringArray(next.Tags).join(","); + } else if (Array.isArray(next.QbitTagsValue)) { + next.QbitTag = normalizeStringArray(next.QbitTagsValue).join(","); + } + } + if (next.VerifyWebUICertificate === undefined && typeof next.TLSSkipVerify === "boolean") { + next.VerifyWebUICertificate = !next.TLSSkipVerify; + } + + legacyTorrentClientKeys.forEach((key) => { + delete next[key]; + }); + + return next; +}; + +/** + * Builds the next qBit direct-connection state for the settings toggle. + * Enabling seeds host defaults; disabling clears direct, proxy, and legacy + * credential fields so the client no longer attempts a direct qBit connection. + */ +export const nextQbitDirectState = (client: ConfigMap, enabled: boolean): ConfigMap => { + if (enabled) { + return { + ...client, + QbitURL: + typeof client.QbitURL === "string" && client.QbitURL.trim() !== "" + ? client.QbitURL + : "http://127.0.0.1", + QbitPort: typeof client.QbitPort === "number" && client.QbitPort > 0 ? client.QbitPort : 8080, + }; + } + return { ...client, ...qbitDirectDisabledValues }; +}; + +/** + * Normalizes all configured torrent client entries in a settings payload before + * serializing it for the backend. + */ +export const normalizeTorrentClientsForSave = (input: ConfigMap) => { + const clients = input.TorrentClients; + if (!clients || typeof clients !== "object" || Array.isArray(clients)) { + return input; + } + + const nextClients: ConfigMap = {}; + Object.entries(clients as ConfigMap).forEach(([name, value]) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return; + } + nextClients[name] = normalizeTorrentClientForSave(value as ConfigMap); + }); + + return { ...input, TorrentClients: nextClients }; +}; + +/** Filters supported trackers to catalog fields and removes all legacy endpoint overrides. */ +const normalizeTrackersForSave = (input: ConfigMap, catalog: TrackerCatalog | null) => { + const trackerRoot = input.Trackers; + if (!trackerRoot || typeof trackerRoot !== "object" || Array.isArray(trackerRoot)) { + return input; + } + const trackerEntries = (trackerRoot as ConfigMap).Trackers; + if (!trackerEntries || typeof trackerEntries !== "object" || Array.isArray(trackerEntries)) { + return input; + } + + let changed = false; + const catalogByName = new Map( + (catalog?.entries ?? []).map((entry) => [entry.name.trim().toUpperCase(), entry]), + ); + const nextEntries: ConfigMap = {}; + Object.entries(trackerEntries as ConfigMap).forEach(([name, value]) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + nextEntries[name] = value; + return; + } + const supported = catalogByName.get(name.trim().toUpperCase()); + const allowed = supported ? new Set(supported.fields.map((field) => field.key)) : null; + const next: ConfigMap = {}; + Object.entries(value as ConfigMap).forEach(([key, fieldValue]) => { + if (key.trim().toLowerCase() === "url") { + changed = true; + return; + } + if (allowed && !allowed.has(key)) { + changed = true; + return; + } + next[key] = fieldValue; + }); + nextEntries[name] = next; + }); + + if (!changed) { + return input; + } + return { + ...input, + Trackers: { + ...(trackerRoot as ConfigMap), + Trackers: nextEntries, + }, + }; +}; + +/** + * Owns settings-screen state, WebUI config loading, sensitive-value masking, + * render helpers, and save payload construction for tabs that need config data. + * Save payloads restore masked secrets before serialization. + */ +export const useSettingsState = (options: UseSettingsStateOptions): UseSettingsStateResult => { + const { activeTab } = options; + const [configData, setConfigData] = useState(null); + const { + catalog: trackerCatalog, + loading: knownTrackersLoading, + error: trackerCatalogError, + removeUnsupported, + } = useTrackerCatalog(); + const [imageHostPolicyMetadata, setImageHostPolicyMetadata] = + useState(null); + const [trackerAddSelection, setTrackerAddSelection] = useState(""); + const [draftTrackerEntries, setDraftTrackerEntries] = useState>({}); + const [settingsTrackerPanels, setSettingsTrackerPanels] = useState>({}); + const [defaultTrackersPanelOpen, setDefaultTrackersPanelOpen] = useState(false); + const [settingsLoading, setSettingsLoading] = useState(false); + const [settingsError, setSettingsError] = useState(""); + const [settingsSaved, setSettingsSaved] = useState(""); + const [settingsDirty, setSettingsDirty] = useState(false); + const [settingsSection, setSettingsSection] = useState(settingsSections[0].key); + const [settingsAdvanced, setSettingsAdvanced] = useState>({}); + const [sensitiveValues, setSensitiveValues] = useState>({}); + + const configuredImageHosts = useMemo(() => { + if (!configData || !configData.ImageHosting || typeof configData.ImageHosting !== "object") { + return [] as string[]; + } + if (Array.isArray(configData.ImageHosting)) { + return [] as string[]; + } + const imageCfg = configData.ImageHosting as ConfigMap; + const hostFields = ["Host1", "Host2", "Host3", "Host4", "Host5", "Host6"]; + const hosts: string[] = []; + hostFields.forEach((field) => { + const value = String(imageCfg[field] ?? "").trim(); + if (!value) return; + if (!hosts.includes(value)) { + hosts.push(value); + } + }); + return hosts; + }, [configData]); + + const screenshotConfig = useMemo(() => { + if ( + !configData || + !configData.ScreenshotHandling || + typeof configData.ScreenshotHandling !== "object" + ) { + return null; + } + if (Array.isArray(configData.ScreenshotHandling)) { + return null; + } + return configData.ScreenshotHandling as ConfigMap; + }, [configData]); + + const torrentClientOptions = useMemo(() => { + if ( + !configData || + !configData.TorrentClients || + typeof configData.TorrentClients !== "object" || + Array.isArray(configData.TorrentClients) + ) { + return []; + } + + return Object.entries(configData.TorrentClients as ConfigMap) + .filter( + ([name, value]) => + name.trim() !== "" && value && typeof value === "object" && !Array.isArray(value), + ) + .map(([name]) => ({ value: name, label: name })); + }, [configData]); + + const effectiveSectionFieldMeta = useMemo>>(() => { + const clientOptions = [{ value: "", label: "" }, ...torrentClientOptions]; + return { + ...sectionFieldMeta, + ClientSetup: { + ...(sectionFieldMeta.ClientSetup ?? {}), + DefaultClient: stringField("DefaultClient", { + label: "Default client", + options: clientOptions, + }), + InjectClients: stringField("InjectClients", { + label: "Injected clients", + options: clientOptions, + }), + SearchClients: stringField("SearchClients", { + label: "Searching clients", + options: clientOptions, + }), + }, + }; + }, [torrentClientOptions]); + + const setSettingsErrorMessage = (message: string) => { + setSettingsError(message); + }; + + const clearSettingsStatus = useCallback(() => { + setSettingsError(""); + setSettingsSaved(""); + }, []); + + const markSettingsSaved = (message: string) => { + setSettingsSaved(message); + setSettingsDirty(false); + }; + + const setSettingsSavedMessage = (message: string) => { + setSettingsSaved(message); + }; + + const buildSavePayload = () => { + if (!configData) { + return null; + } + const restored = normalizeTrackersForSave( + normalizeTorrentClientsForSave(restoreSensitiveConfig(configData, sensitiveValues)), + trackerCatalog, + ); + return JSON.stringify(restored, null, 2); + }; + + const resolveImageHostLabel = (value: string) => { + return imageHostOptionLabels.get(value) ?? value; + }; + + const buildImageHostOptions = useCallback((hosts: string[]) => { + const allowed = new Set( + hosts.map((host) => normalizeImageHostValue(host)).filter((host) => host.length > 0), + ); + const ordered = imageHostOptions.filter( + (option) => option.value === "" || allowed.has(option.value), + ); + const known = new Set(ordered.map((option) => option.value)); + const extras = Array.from(allowed) + .filter((host) => !known.has(host)) + .sort((left, right) => left.localeCompare(right)) + .map(imageHostOptionFor); + return [...ordered, ...extras]; + }, []); + + const trackerOptionsForImageHost = useCallback( + (trackerName: string) => { + const trackerKey = trackerName.trim().toUpperCase(); + if (!imageHostPolicyMetadata) { + return imageHostOptions; + } + + const policyHosts = imageHostPolicyMetadata.TrackerUploadHosts?.[trackerKey]; + const fallbackHosts = + imageHostPolicyMetadata.UploadHosts?.map((host) => normalizeImageHostValue(host)) ?? + imageHostOptions.filter((option) => option.value).map((option) => option.value); + const ownerByHost = imageHostPolicyMetadata.OwnedHosts ?? {}; + const imageCfg = + configData?.ImageHosting && + typeof configData.ImageHosting === "object" && + !Array.isArray(configData.ImageHosting) + ? (configData.ImageHosting as ConfigMap) + : null; + const trackerRoot = + configData?.Trackers && + typeof configData.Trackers === "object" && + !Array.isArray(configData.Trackers) + ? (configData.Trackers as ConfigMap) + : null; + const trackerEntries = + trackerRoot?.Trackers && + typeof trackerRoot.Trackers === "object" && + !Array.isArray(trackerRoot.Trackers) + ? (trackerRoot.Trackers as ConfigMap) + : null; + const trackerCfg = + trackerEntries?.[trackerKey] && + typeof trackerEntries[trackerKey] === "object" && + !Array.isArray(trackerEntries[trackerKey]) + ? (trackerEntries[trackerKey] as ConfigMap) + : null; + const globalFallbackHosts = fallbackHosts.filter((host) => !ownerByHost[host]); + const globalHosts = (configuredImageHosts.length ? configuredImageHosts : globalFallbackHosts) + .map((host) => normalizeImageHostValue(host)) + .filter((host) => host.length > 0 && !ownerByHost[host]); + const policyHostSet = new Set( + (policyHosts ?? []).map((host) => normalizeImageHostValue(host)).filter(Boolean), + ); + const policyHasGlobalHosts = Array.from(policyHostSet).some((host) => !ownerByHost[host]); + const policyAllowsGlobalFallback = + policyHostSet.size === 0 || + Array.from(policyHostSet).every((host) => host === "lostimg" || host === "reelflix"); + const hosts = globalHosts.filter( + (host) => policyAllowsGlobalFallback || (policyHasGlobalHosts && policyHostSet.has(host)), + ); + + (policyHosts ?? []).forEach((host) => { + const normalizedHost = normalizeImageHostValue(host); + const owner = ownerByHost[normalizedHost]; + if (!owner || owner.trim().toUpperCase() !== trackerKey) { + return; + } + if (normalizedHost === "lostimg" && !Boolean(imageCfg?.LostimgEnabled)) { + return; + } + if ( + normalizedHost === "reelflix" && + normalizeImageHostValue(String(trackerCfg?.ImageHost ?? "")) !== "reelflix" + ) { + return; + } + if (!hosts.includes(normalizedHost)) { + hosts.push(normalizedHost); + } + }); + + return buildImageHostOptions(hosts); + }, + [buildImageHostOptions, configData, configuredImageHosts, imageHostPolicyMetadata], + ); + + const updateConfigValue = (path: string[], value: ConfigValue) => { + setConfigData((prev) => { + if (!prev) return prev; + const clone = structuredClone(prev) as ConfigMap; + let cursor: ConfigMap = clone; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + const next = cursor[key]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as ConfigMap; + } + cursor[path[path.length - 1]] = value; + setSettingsDirty(true); + return clone; + }); + + const key = path[path.length - 1] || ""; + if ( + typeof value === "string" && + (isSensitiveKeyName(key) || sensitiveValues[buildPathKey(path)] !== undefined) + ) { + setSensitiveValues((prev) => { + const next = { ...prev }; + const pathKey = buildPathKey(path); + if (value === REDACTED_VALUE) { + return prev; + } + if (!value) { + delete next[pathKey]; + return next; + } + next[pathKey] = value; + return next; + }); + } + }; + + const updateScreenshotConfigValue = (key: string, value: ConfigValue) => { + updateConfigValue(["ScreenshotHandling", key], value); + }; + + const removeConfigKey = (path: string[], key: string) => { + setConfigData((prev) => { + if (!prev) return prev; + const clone = structuredClone(prev) as ConfigMap; + let cursor: ConfigMap = clone; + for (let i = 0; i < path.length; i += 1) { + cursor = cursor[path[i]] as ConfigMap; + if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) { + return prev; + } + } + if (!Object.prototype.hasOwnProperty.call(cursor, key)) { + return prev; + } + delete cursor[key]; + setSettingsDirty(true); + return clone; + }); + }; + + const addConfigKey = (path: string[], key: string, value: ConfigValue) => { + if (!key.trim()) return; + setConfigData((prev) => { + if (!prev) return prev; + const clone = structuredClone(prev) as ConfigMap; + let cursor: ConfigMap = clone; + for (let i = 0; i < path.length; i += 1) { + const step = path[i]; + const next = cursor[step]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + cursor[step] = {}; + } + cursor = cursor[step] as ConfigMap; + } + if (cursor[key] !== undefined) return prev; + cursor[key] = value; + setSettingsDirty(true); + return clone; + }); + }; + + const loadSettings = useCallback(async () => { + clearSettingsStatus(); + const getConfig = configClient.get; + setSettingsLoading(true); + try { + const result = await getConfig(); + const parsed = JSON.parse(result) as ConfigMap; + const masked = maskSensitiveConfig(parsed); + setConfigData(masked.masked); + setSensitiveValues(masked.originals); + setDraftTrackerEntries({}); + setSettingsDirty(false); + } catch (err) { + setSettingsError(String(err)); + } finally { + setSettingsLoading(false); + } + }, [clearSettingsStatus]); + + const loadImageHostPolicyMetadata = useCallback(async () => { + const getMetadata = trackerCatalogClient.getImageHostPolicyMetadata; + try { + const result = await getMetadata(); + if (result && typeof result === "object") { + setImageHostPolicyMetadata(result); + } + } catch (err) { + setSettingsError(String(err)); + } + }, []); + + const handleSaveSettings = async () => { + clearSettingsStatus(); + const saveConfig = configClient.save; + const payload = buildSavePayload(); + if (!payload) { + setSettingsError("Settings are not loaded."); + return; + } + setSettingsLoading(true); + try { + await saveConfig(payload); + markSettingsSaved("Settings saved and applied."); + } catch (err) { + setSettingsError(String(err)); + } finally { + setSettingsLoading(false); + } + }; + + useEffect(() => { + if ( + (activeTab === "input" || + activeTab === "tracker" || + activeTab === "dupes" || + activeTab === "settings" || + activeTab === "logging" || + activeTab === "upload" || + activeTab === "upload_images") && + !configData + ) { + loadSettings(); + } + }, [activeTab, configData, loadSettings]); + + useEffect(() => { + try { + trackerCatalog?.entries.forEach((entry) => { + entry.fields.forEach((field) => trackerFieldPresentation(field.key)); + }); + } catch (error) { + setSettingsError(String(error)); + } + }, [trackerCatalog]); + + useEffect(() => { + if (trackerCatalogError) setSettingsError(trackerCatalogError); + }, [trackerCatalogError]); + + useEffect(() => { + if (activeTab === "settings" && !imageHostPolicyMetadata) { + loadImageHostPolicyMetadata(); + } + }, [activeTab, imageHostPolicyMetadata, loadImageHostPolicyMetadata]); + + useEffect(() => { + if ((activeTab === "screenshots" || activeTab === "upload_images") && !configData) { + loadSettings(); + } + }, [activeTab, configData, loadSettings]); + + const advancedOpen = settingsAdvanced[settingsSection] ?? false; + const showAdvancedToggle = (() => { + if (settingsSection === "trackers") { + return (trackerCatalog?.entries ?? []).some((entry) => + entry.fields.some((field) => trackerFieldPresentation(field.key).advanced), + ); + } + const section = settingsSections.find((item) => item.key === settingsSection); + if (!section) return false; + const meta = effectiveSectionFieldMeta[section.jsonKey]; + if (!meta) return false; + return Object.values(meta).some((field) => field.advanced); + })(); + + const renderArrayEditor = ( + value: ConfigValue[], + path: string[], + meta?: FieldMeta, + displayLabel?: string, + ) => { + const options = meta?.options ?? []; + const optionValueFor = (entry: ConfigValue) => (entry === null ? "" : String(entry ?? "")); + const optionsFor = (entry: ConfigValue) => { + const selected = optionValueFor(entry); + if (!selected || options.some((option) => option.value === selected)) { + return options; + } + return [...options, { value: selected, label: selected }]; + }; + const newItemValue = options.find((option) => option.value !== "")?.value ?? ""; + + return ( +
+ {value.map((entry, index) => ( +
+ {options.length > 0 ? ( + + ) : ( + { + const updated = [...value]; + updated[index] = event.target.value; + updateConfigValue(path, updated); + }} + /> + )} + +
+ ))} + +
+ ); + }; + + const renderField = (label: string, value: ConfigValue, path: string[], meta?: FieldMeta) => { + const displayLabel = meta?.label ?? formatLabel(label); + const typeHint = meta?.type; + if (Array.isArray(value)) { + return ( +
+ {displayLabel} + {renderArrayEditor(value, path, meta, displayLabel)} +
+ ); + } + if (meta?.options && meta.options.length > 0) { + const selectedValue = value === null ? "" : String(value ?? ""); + const options = meta.options.some((option) => option.value === selectedValue) + ? meta.options + : [...meta.options, { value: selectedValue, label: selectedValue }]; + return ( + + ); + } + if (typeHint === "boolean" || typeof value === "boolean") { + return ( +
+ {displayLabel} + updateConfigValue(path, event.target.checked)} + /> +
+ ); + } + if (typeHint === "number" || typeof value === "number") { + const numericValue = typeof value === "number" && Number.isFinite(value) ? value : 0; + return ( + + ); + } + if (value && typeof value === "object") { + return ( +
+
{displayLabel}
+
+ {Object.entries(value).map(([childKey, childValue]) => + renderField(childKey, childValue, [...path, childKey]), + )} +
+
+ ); + } + + return ( + + ); + }; + + const renderMapSection = ( + sectionKey: string, + sectionValue: ConfigMap, + options?: { + entriesKey?: string; + defaultKey?: string; + fieldMeta?: Record; + advancedOpen?: boolean; + }, + ) => { + const entriesRoot = options?.entriesKey + ? (sectionValue[options.entriesKey] as ConfigMap) || {} + : sectionValue; + const entries = Object.entries(entriesRoot).filter( + ([, value]) => value && typeof value === "object" && !Array.isArray(value), + ) as Array<[string, ConfigMap]>; + const defaultKey = options?.defaultKey; + const fieldMeta = options?.fieldMeta || {}; + const advancedOpen = options?.advancedOpen ?? false; + + return ( +
+ {defaultKey ? ( +
+
{formatLabel(defaultKey)}
+ {renderField(defaultKey, sectionValue[defaultKey] as ConfigValue, [ + sectionKey, + defaultKey, + ])} +
+ ) : null} + +
+

Entries

+ +
+ +
+ {entries.length === 0 ? ( +

No entries yet.

+ ) : ( + entries.map(([key, value]) => ( +
+
+

{key}

+ +
+
+ {Object.entries(value) + .filter(([childKey]) => { + const meta = fieldMeta[childKey]; + if (meta?.advanced && !advancedOpen) return false; + return true; + }) + .map(([childKey, childValue]) => + renderField( + childKey, + childValue, + [sectionKey, options?.entriesKey || "", key, childKey].filter(Boolean), + fieldMeta[childKey], + ), + )} +
+
+ )) + )} +
+
+ ); + }; + + const renderTorrentClientsSection = (advancedOpen: boolean) => { + if ( + !configData || + !configData.TorrentClients || + typeof configData.TorrentClients !== "object" || + Array.isArray(configData.TorrentClients) + ) { + return null; + } + + const clients = Object.entries(configData.TorrentClients as ConfigMap).filter( + ([, value]) => value && typeof value === "object" && !Array.isArray(value), + ) as Array<[string, ConfigMap]>; + const meta = effectiveSectionFieldMeta.TorrentClients || {}; + const valueFor = (client: ConfigMap, primary: string, fallback?: string) => { + const primaryValue = client[primary]; + if (primaryValue !== undefined && primaryValue !== null && primaryValue !== "") { + return primaryValue; + } + return fallback ? client[fallback] : primaryValue; + }; + const arrayFor = (client: ConfigMap, key: string) => { + const value = client[key]; + return Array.isArray(value) ? value : []; + }; + const qbitTagFor = (client: ConfigMap) => { + const direct = client.QbitTag; + if (typeof direct === "string" && direct.trim() !== "") return direct; + const qbitTags = normalizeStringArray(client.QbitTagsValue); + if (qbitTags.length > 0) return qbitTags.join(","); + return normalizeStringArray(client.Tags).join(","); + }; + const hasDirectConfig = (client: ConfigMap) => + ["QbitURL", "QbitPort", "QbitUser", "QbitPass", "URL", "Username", "Password"].some((key) => { + const value = client[key]; + if (typeof value === "number") return value > 0; + return typeof value === "string" && value.trim() !== ""; + }); + const setQbitDirect = (name: string, enabled: boolean) => { + const client = clients.find(([clientName]) => clientName === name)?.[1]; + if (!client) { + return; + } + const nextClient = nextQbitDirectState(client, enabled); + for (const key of [ + "QbitURL", + "QbitPort", + "QbitUser", + "QbitPass", + "URL", + "Username", + "Password", + "QuiProxyURL", + ]) { + if (client[key] === nextClient[key]) { + continue; + } + updateConfigValue(["TorrentClients", name, key], nextClient[key]); + } + }; + + return ( +
+
+

Entries

+ +
+ +
+ {clients.length === 0 ? ( +

No entries yet.

+ ) : ( + clients.map(([name, client]) => { + const clientType = normalizeTorrentClientType(client); + const watchClient = clientType === "watch"; + const directEnabled = !watchClient && hasDirectConfig(client); + return ( +
+
+

{name}

+ +
+
+ {renderField( + "Type", + valueFor(client, "Type", "TorrentClient") ?? "qbit", + ["TorrentClients", name, "Type"], + meta.Type, + )} + {watchClient + ? renderField( + "WatchFolder", + client.WatchFolder ?? "", + ["TorrentClients", name, "WatchFolder"], + meta.WatchFolder, + ) + : null} + {watchClient + ? renderField( + "StorageDir", + client.StorageDir ?? "", + ["TorrentClients", name, "StorageDir"], + meta.StorageDir, + ) + : null} + {!watchClient + ? renderField( + "QuiProxyURL", + client.QuiProxyURL ?? "", + ["TorrentClients", name, "QuiProxyURL"], + meta.QuiProxyURL, + ) + : null} + {!watchClient + ? renderField( + "QbitCategoryValue", + valueFor(client, "QbitCategoryValue", "Category") ?? "", + ["TorrentClients", name, "QbitCategoryValue"], + meta.QbitCategoryValue, + ) + : null} + {!watchClient + ? renderField( + "QbitTag", + qbitTagFor(client), + ["TorrentClients", name, "QbitTag"], + meta.QbitTag, + ) + : null} + {!watchClient + ? renderField( + "QbitCrossCategory", + client.QbitCrossCategory ?? "", + ["TorrentClients", name, "QbitCrossCategory"], + meta.QbitCrossCategory, + ) + : null} + {!watchClient + ? renderField( + "QbitCrossTag", + client.QbitCrossTag ?? "", + ["TorrentClients", name, "QbitCrossTag"], + meta.QbitCrossTag, + ) + : null} + {!watchClient + ? renderField( + "UseTrackerAsTag", + client.UseTrackerAsTag ?? false, + ["TorrentClients", name, "UseTrackerAsTag"], + meta.UseTrackerAsTag, + ) + : null} + {!watchClient + ? renderField( + "Linking", + client.Linking ?? "", + ["TorrentClients", name, "Linking"], + meta.Linking, + ) + : null} + {!watchClient ? ( +
+ Allow link fallback + + updateConfigValue( + ["TorrentClients", name, "AllowFallback"], + event.target.checked, + ) + } + /> +
+ ) : null} + {!watchClient + ? renderField( + "LinkedFolder", + arrayFor(client, "LinkedFolder"), + ["TorrentClients", name, "LinkedFolder"], + meta.LinkedFolder, + ) + : null} + {!watchClient + ? renderField( + "LocalPath", + arrayFor(client, "LocalPath"), + ["TorrentClients", name, "LocalPath"], + meta.LocalPath, + ) + : null} + {!watchClient + ? renderField( + "RemotePath", + arrayFor(client, "RemotePath"), + ["TorrentClients", name, "RemotePath"], + meta.RemotePath, + ) + : null} + {!watchClient + ? renderField( + "AutomaticManagementPaths", + arrayFor(client, "AutomaticManagementPaths"), + ["TorrentClients", name, "AutomaticManagementPaths"], + meta.AutomaticManagementPaths, + ) + : null} + {!watchClient && advancedOpen + ? renderField( + "VerifyWebUICertificate", + client.VerifyWebUICertificate ?? true, + ["TorrentClients", name, "VerifyWebUICertificate"], + meta.VerifyWebUICertificate, + ) + : null} +
+ + {!watchClient ? ( +
+ qBit direct + setQbitDirect(name, event.target.checked)} + /> +
+ ) : null} + + {!watchClient && directEnabled ? ( +
+ {renderField( + "QbitURL", + valueFor(client, "QbitURL", "URL") ?? "", + ["TorrentClients", name, "QbitURL"], + meta.QbitURL, + )} + {renderField( + "QbitPort", + client.QbitPort ?? 0, + ["TorrentClients", name, "QbitPort"], + meta.QbitPort, + )} + {renderField( + "QbitUser", + valueFor(client, "QbitUser", "Username") ?? "", + ["TorrentClients", name, "QbitUser"], + meta.QbitUser, + )} + {renderField( + "QbitPass", + valueFor(client, "QbitPass", "Password") ?? "", + ["TorrentClients", name, "QbitPass"], + meta.QbitPass, + )} +
+ ) : null} +
+ ); + }) + )} +
+
+ ); + }; + + /** Reports configured state from backend-owned activation markers. */ + const isTrackerConfigured = useCallback( + (entry: TrackerCatalogEntry, trackerValue: ConfigMap): boolean => + entry.fields.some( + (field) => + field.activation && hasConfiguredTrackerValue(trackerValue[field.key], field.default), + ), + [], + ); + + const trackerSelectionNames = useMemo(() => { + if ( + !configData || + !configData.Trackers || + typeof configData.Trackers !== "object" || + Array.isArray(configData.Trackers) + ) { + return [] as string[]; + } + + const trackerRoot = configData.Trackers as ConfigMap; + const rawEntries = trackerRoot.Trackers; + const entriesRoot = + rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries) + ? (rawEntries as ConfigMap) + : {}; + const draftTrackerSet = new Set( + Object.entries(draftTrackerEntries) + .filter(([, enabled]) => enabled) + .map(([name]) => name), + ); + + return (trackerCatalog?.entries ?? []) + .filter((entry) => { + const value = trackerConfigValue(entriesRoot, entry.name); + return ( + draftTrackerSet.has(entry.name) || + (value ? isTrackerConfigured(entry, value) : entry.configured) + ); + }) + .map((entry) => entry.name); + }, [configData, draftTrackerEntries, isTrackerConfigured, trackerCatalog]); + + const renderTrackerSection = (advancedOpen: boolean) => { + try { + if ( + !configData || + !configData.Trackers || + typeof configData.Trackers !== "object" || + Array.isArray(configData.Trackers) + ) { + return null; + } + + const trackerRoot = configData.Trackers as ConfigMap; + const defaultTrackers = (trackerRoot.DefaultTrackers as ConfigValue) ?? []; + const rawEntries = trackerRoot.Trackers; + const entriesRoot = + rawEntries && typeof rawEntries === "object" && !Array.isArray(rawEntries) + ? (rawEntries as ConfigMap) + : {}; + + const visibleTrackerSet = new Set(trackerSelectionNames); + const catalogEntries = trackerCatalog?.entries ?? []; + const visibleEntries = catalogEntries + .filter((entry) => visibleTrackerSet.has(entry.name)) + .map((entry) => ({ entry, value: trackerConfigValue(entriesRoot, entry.name) ?? {} })); + + const normalizedDefaultTrackers = normalizeDefaultTrackerList(defaultTrackers); + const preferredTrackerRaw = trackerRoot.PreferredTracker; + const preferredTracker = + typeof preferredTrackerRaw === "string" ? preferredTrackerRaw.trim() : ""; + const trackerNames = trackerSelectionNames; + const normalizedTrackerNameSet = new Set(trackerNames.map((name) => name.toLowerCase())); + const selectedDefaultTrackerCount = normalizedDefaultTrackers.filter((name) => + normalizedTrackerNameSet.has(name.toLowerCase()), + ).length; + const trackerOptions = catalogEntries.map((entry) => entry.name); + const availableTrackers = trackerOptions.filter((name) => !visibleTrackerSet.has(name)); + const trackerClientOptions = [{ value: "", label: "" }, ...torrentClientOptions]; + + const trackerSchemaFor = (entry: TrackerCatalogEntry) => { + return entry.fields.map((field) => { + const base = trackerFieldPresentation(field.key); + if (field.key === "ImageHost") { + return { ...base, options: trackerOptionsForImageHost(entry.name) }; + } + if (field.key === "TorrentClient") { + return { ...base, options: trackerClientOptions }; + } + return base; + }); + }; + + const buildTrackerDefaults = (entry: TrackerCatalogEntry) => { + const defaults: ConfigMap = {}; + entry.fields.forEach((field) => { + defaults[field.key] = structuredClone(field.default); + }); + return defaults; + }; + + const toggleDefaultTracker = (name: string, enabled: boolean) => { + const current = normalizeDefaultTrackerList(defaultTrackers); + const next = current.filter((entry) => entry !== name); + if (enabled) { + next.push(name); + } + updateConfigValue(["Trackers", "DefaultTrackers"], next); + }; + + const updatePreferredTracker = (value: string) => { + updateConfigValue(["Trackers", "PreferredTracker"], value.trim()); + }; + + return ( +
+
{ + const target = event.currentTarget as HTMLDetailsElement; + setDefaultTrackersPanelOpen(target.open); + }} + > + + Default trackers + + {selectedDefaultTrackerCount}/{trackerNames.length} + + +
+ {trackerNames.length === 0 ? ( +

Add tracker entries to select defaults.

+ ) : ( +
+
+ {trackerNames.map((tracker) => ( + toggleDefaultTracker(tracker, checked)} + > + {tracker} + + ))} +
+
+ )} +
+
+ +
+ Preferred tracker data source +
+
+ + +
+

+ Moves the selected tracker to the top of tracker-data lookup and qBit tracker + priority when present. +

+
+
+ +
+

Entries

+
+ + +
+
+ +
+ {visibleEntries.length === 0 ? ( +

No configured entries yet.

+ ) : ( + visibleEntries.map(({ entry, value }) => { + const key = entry.name; + const schema = trackerSchemaFor(entry); + return ( +
{ + const target = event.currentTarget as HTMLDetailsElement; + setSettingsTrackerPanels((prev) => ({ ...prev, [key]: target.open })); + }} + > + + {key} + + +
+
+ {schema + .filter((meta) => !(meta.advanced && !advancedOpen)) + .map((meta) => + renderField( + meta.key, + value[meta.key] ?? + entry.fields.find((field) => field.key === meta.key)?.default ?? + "", + ["Trackers", "Trackers", key, meta.key], + meta, + ), + )} +
+
+
+ ); + }) + )} +
+ + {(trackerCatalog?.unsupported.length ?? 0) > 0 ? ( +
+
Unsupported tracker entries
+

+ Preserved config has no matching tracker implementation and cannot be used. +

+
+ {trackerCatalog?.unsupported.map((name) => ( +
+
+ {name} + +
+
+ ))} +
+
+ ) : null} +
+ ); + } catch (err) { + return

Unable to render tracker settings: {String(err)}

; + } + }; + + const renderImageHostingSection = () => { + if (!configData || !configData.ImageHosting || typeof configData.ImageHosting !== "object") { + return null; + } + + const imageCfg = configData.ImageHosting as ConfigMap; + const hostFields = ["Host1", "Host2", "Host3", "Host4", "Host5", "Host6"]; + const requiredKeys = new Set(); + hostFields.forEach((field) => { + const selected = String(imageCfg[field] ?? "").trim(); + if (!selected) return; + const keys = imageHostKeyMap[selected]; + if (keys) { + keys.forEach((key) => requiredKeys.add(key)); + } + }); + + return ( +
+
+
Host Priority
+
+ {hostFields.map((field, index) => ( + + ))} +
+
+ +
+
API Keys
+ {requiredKeys.size === 0 ? ( +

Select an image host to edit its API keys.

+ ) : ( +
+ {Array.from(requiredKeys).map((key) => + renderField(key, imageCfg[key] as ConfigValue, ["ImageHosting", key]), + )} +
+ )} +
+ +
+
Additional Hosts
+
+
+ Lostimg enabled + + updateConfigValue(["ImageHosting", "LostimgEnabled"], event.target.checked) + } + /> +
+ {renderField( + "LostimgAPI", + (imageCfg.LostimgAPI as ConfigValue) ?? "", + ["ImageHosting", "LostimgAPI"], + sectionFieldMeta.ImageHosting.LostimgAPI, + )} +
+
+
+ ); + }; + + return { + configData, + settingsLoading, + settingsDirty, + settingsSaved, + settingsError, + settingsSection, + settingsSections, + showAdvancedToggle, + advancedOpen, + setSettingsSection, + setSettingsAdvanced, + loadSettings, + handleSaveSettings, + renderImageHostingSection, + renderTrackerSection, + renderTorrentClientsSection, + renderMapSection, + renderField, + sectionFieldMeta: effectiveSectionFieldMeta, + updateConfigValue, + updateScreenshotConfigValue, + configuredImageHosts, + screenshotConfig, + buildSavePayload, + clearSettingsStatus, + markSettingsSaved, + setSettingsSavedMessage, + setSettingsErrorMessage, + resolveImageHostLabel, + knownTrackersLoading, + trackerSelectionNames, + }; +}; diff --git a/gui/frontend/src/hooks/useTrackerIcons.test.ts b/webui/src/hooks/useTrackerIcons.test.ts similarity index 90% rename from gui/frontend/src/hooks/useTrackerIcons.test.ts rename to webui/src/hooks/useTrackerIcons.test.ts index 01620ac4..87d7c59d 100644 --- a/gui/frontend/src/hooks/useTrackerIcons.test.ts +++ b/webui/src/hooks/useTrackerIcons.test.ts @@ -3,6 +3,7 @@ import { cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { installAppOperationMocks } from "../test/appRequestMock"; import type { TrackerUploadItem } from "../types"; import { useTrackerIcons } from "./useTrackerIcons"; @@ -14,7 +15,7 @@ afterEach(() => { describe("useTrackerIcons", () => { it("does not fetch when favicons are disabled", () => { const getTrackerIcon = vi.fn().mockResolvedValue("data:image/png;base64,a"); - vi.stubGlobal("go", { guiapp: { App: { GetTrackerIcon: getTrackerIcon } } }); + installAppOperationMocks({ GetTrackerIcon: getTrackerIcon }); const trackers: TrackerUploadItem[] = [{ name: "AITHER", config: {} }]; const { result } = renderHook(() => useTrackerIcons(trackers, false)); @@ -25,7 +26,7 @@ describe("useTrackerIcons", () => { it("fetches configured tracker icons through the app-level cache", async () => { const getTrackerIcon = vi.fn().mockResolvedValue("data:image/png;base64,a"); - vi.stubGlobal("go", { guiapp: { App: { GetTrackerIcon: getTrackerIcon } } }); + installAppOperationMocks({ GetTrackerIcon: getTrackerIcon }); const trackers: TrackerUploadItem[] = [ { name: "AITHER", config: { FaviconURL: "https://icons.example/aither.png" } }, ]; @@ -43,7 +44,7 @@ describe("useTrackerIcons", () => { .fn() .mockResolvedValueOnce("data:image/png;base64,a") .mockResolvedValueOnce("data:image/png;base64,b"); - vi.stubGlobal("go", { guiapp: { App: { GetTrackerIcon: getTrackerIcon } } }); + installAppOperationMocks({ GetTrackerIcon: getTrackerIcon }); const firstTrackers: TrackerUploadItem[] = [{ name: "AITHER", config: {} }]; const nextTrackers: TrackerUploadItem[] = [ { name: "AITHER", config: {} }, diff --git a/gui/frontend/src/hooks/useTrackerIcons.ts b/webui/src/hooks/useTrackerIcons.ts similarity index 96% rename from gui/frontend/src/hooks/useTrackerIcons.ts rename to webui/src/hooks/useTrackerIcons.ts index dff29905..6e092582 100644 --- a/gui/frontend/src/hooks/useTrackerIcons.ts +++ b/webui/src/hooks/useTrackerIcons.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later import { useEffect, useRef, useState } from "react"; +import { trackerCatalogClient } from "../api/app"; import type { TrackerUploadItem } from "../types"; import { getTrackerDomain } from "../utils/favicon"; @@ -49,8 +50,7 @@ export function useTrackerIcons( } }); - const getTrackerIcon = globalThis.go?.guiapp?.App?.GetTrackerIcon; - if (!getTrackerIcon) return; + const getTrackerIcon = trackerCatalogClient.getIcon; let active = true; trackerUploadItems.forEach((item) => { diff --git a/webui/src/jobRegistry/coordinator.test.ts b/webui/src/jobRegistry/coordinator.test.ts new file mode 100644 index 00000000..29527ca6 --- /dev/null +++ b/webui/src/jobRegistry/coordinator.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { describe, expect, it, vi } from "vitest"; +import type { OwnerJobSnapshot, ReleaseRef } from "../types"; +import { JobRegistryCoordinator } from "./coordinator"; +import type { JobRegistryClock, JobRegistryTransport } from "./types"; + +const release: ReleaseRef = { SourcePath: "C:\\media\\Example", Generation: 3 }; + +const dupeJob = ( + correlationID = "correlation-1", +): Extract => { + const dupe = { + jobID: "dupe-1", + correlationID, + release, + runtimeGeneration: 2, + status: "running", + trackers: [], + completedCount: 0, + totalCount: 1, + summary: { + SourcePath: release.SourcePath, + Results: [], + Notes: [], + Eligibility: { Release: release, Trackers: [], EligibleTrackers: [] }, + }, + startedAt: "2026-07-15T00:00:00Z", + finishedAt: "", + }; + return { + kind: "duplicate_check", + jobID: dupe.jobID, + correlationID, + release, + status: dupe.status, + startedAt: dupe.startedAt, + finishedAt: "", + dupe, + }; +}; + +const uploadJob = ( + jobID = "upload-1", + correlationID = "upload-correlation", + retryOf?: string, +): Extract => ({ + kind: "tracker_upload", + jobID, + correlationID, + retryOf, + release, + status: "failed", + startedAt: "2026-07-15T00:00:01Z", + finishedAt: "2026-07-15T00:00:02Z", + upload: { + jobID, + correlationID, + retryOf, + release, + runtimeGeneration: 2, + status: "failed", + currentTask: "upload", + currentTaskStatus: "failed", + currentMessage: "Upload failed.", + currentCompletedPieces: 0, + currentTotalPieces: 1, + currentPercent: 0, + currentHashRateMiB: 0, + trackers: [], + failedTrackers: ["GRP"], + uploadedCount: 0, + startedAt: "2026-07-15T00:00:01Z", + finishedAt: "2026-07-15T00:00:02Z", + }, +}); + +class FakeClock implements JobRegistryClock { + callback: (() => void) | null = null; + cleared = false; + + setInterval(callback: () => void) { + this.callback = callback; + return "poll"; + } + + clearInterval() { + this.cleared = true; + this.callback = null; + } + + tick() { + this.callback?.(); + } +} + +const transportHarness = () => { + let jobs: OwnerJobSnapshot[] = []; + let listener: ((snapshot: OwnerJobSnapshot) => void) | null = null; + const transport = { + list: vi.fn(async () => jobs), + subscribe: vi.fn((next) => { + listener = next; + return () => { + listener = null; + }; + }), + startDupe: vi.fn(async (_command, correlationID) => { + jobs = [dupeJob(correlationID)]; + return "dupe-1"; + }), + startUpload: vi.fn(async (_token: string, _correlationID: string) => "upload-1"), + retryUpload: vi.fn(async (_jobID: string, _correlationID: string) => "upload-2"), + cancelDupe: vi.fn(async () => undefined), + cancelUpload: vi.fn(async () => undefined), + } satisfies JobRegistryTransport; + return { + transport, + setJobs: (next: OwnerJobSnapshot[]) => { + jobs = next; + }, + emit: (snapshot: OwnerJobSnapshot) => listener?.(snapshot), + hasListener: () => listener !== null, + }; +}; + +describe("JobRegistryCoordinator", () => { + it("bootstraps, consumes one SSE stream, and polls through the owner listing", async () => { + const harness = transportHarness(); + const clock = new FakeClock(); + harness.setJobs([dupeJob()]); + const registry = new JobRegistryCoordinator( + "owner-a", + harness.transport, + clock, + 10, + () => "next-correlation", + ); + registry.start(); + await vi.waitFor(() => expect(registry.snapshot().bootstrapped).toBe(true)); + expect(registry.snapshot().jobs).toHaveLength(1); + expect(harness.transport.subscribe).toHaveBeenCalledOnce(); + + harness.emit({ + ...dupeJob(), + status: "completed", + dupe: { ...dupeJob().dupe, status: "completed" }, + }); + expect(registry.snapshot().jobs[0].status).toBe("completed"); + clock.tick(); + await vi.waitFor(() => expect(harness.transport.list).toHaveBeenCalledTimes(2)); + + registry.dispose(); + expect(clock.cleared).toBe(true); + expect(harness.hasListener()).toBe(false); + }); + + it("registers a pending correlation before awaiting start", async () => { + const harness = transportHarness(); + const registry = new JobRegistryCoordinator( + "owner-a", + harness.transport, + new FakeClock(), + 10, + () => "pending-correlation", + ); + harness.transport.startDupe = vi.fn(async (_command, correlationID) => { + expect(registry.snapshot().pending[0]?.correlationID).toBe(correlationID); + harness.setJobs([dupeJob(correlationID)]); + return "dupe-1"; + }); + registry.start(); + await registry.startDupe({ release, trackers: ["AITHER"] }); + + await vi.waitFor(() => expect(registry.snapshot().pending).toEqual([])); + expect(registry.snapshot().jobs[0].correlationID).toBe("pending-correlation"); + registry.dispose(); + }); + + it("does not restore pending state when SSE accepts a start before its response", async () => { + const harness = transportHarness(); + let resolveStart: (jobID: string) => void = () => undefined; + const startResponse = new Promise((resolve) => { + resolveStart = resolve; + }); + harness.transport.startDupe = vi.fn(() => startResponse); + const registry = new JobRegistryCoordinator( + "owner-a", + harness.transport, + new FakeClock(), + 10, + () => "sse-first-correlation", + ); + registry.start(); + await vi.waitFor(() => expect(registry.snapshot().bootstrapped).toBe(true)); + + const starting = registry.startDupe({ release, trackers: ["AITHER"] }); + expect(registry.snapshot().pending).toHaveLength(1); + const accepted = dupeJob("sse-first-correlation"); + harness.setJobs([accepted]); + harness.emit(accepted); + expect(registry.snapshot().pending).toEqual([]); + + resolveStart("dupe-1"); + await expect(starting).resolves.toBe("dupe-1"); + expect(registry.snapshot().pending).toEqual([]); + registry.dispose(); + }); + + it("recovers an accepted start after its response is lost", async () => { + const harness = transportHarness(); + const registry = new JobRegistryCoordinator( + "owner-a", + harness.transport, + new FakeClock(), + 10, + () => "lost-correlation", + ); + harness.transport.startDupe = vi.fn(async (_command, correlationID) => { + harness.setJobs([dupeJob(correlationID)]); + throw new Error("connection lost"); + }); + registry.start(); + + await expect(registry.startDupe({ release, trackers: ["AITHER"] })).resolves.toBe("dupe-1"); + expect(registry.snapshot().pending).toEqual([]); + registry.dispose(); + }); + + it("keeps transient listing errors separate from canonical Job state", async () => { + const harness = transportHarness(); + const registry = new JobRegistryCoordinator("owner-a", harness.transport, new FakeClock()); + harness.setJobs([dupeJob()]); + await registry.refresh(); + harness.transport.list = vi.fn(async () => { + throw new Error("poll unavailable"); + }); + await registry.refresh(); + + expect(registry.snapshot().jobs).toHaveLength(1); + expect(registry.snapshot().transientError).toBe("poll unavailable"); + }); + + it("routes explicit cancellation by Job kind", async () => { + const harness = transportHarness(); + const registry = new JobRegistryCoordinator("owner-a", harness.transport, new FakeClock()); + + await registry.cancel(dupeJob()); + await registry.cancel(uploadJob()); + + expect(harness.transport.cancelDupe).toHaveBeenCalledWith("dupe-1"); + expect(harness.transport.cancelUpload).toHaveBeenCalledWith("upload-1"); + }); + + it("registers linked upload retries before awaiting the replacement", async () => { + const harness = transportHarness(); + const registry = new JobRegistryCoordinator( + "owner-a", + harness.transport, + new FakeClock(), + 10, + () => "retry-correlation", + ); + harness.transport.retryUpload = vi.fn(async (jobID, correlationID) => { + expect(registry.snapshot().pending).toEqual([ + expect.objectContaining({ + correlationID, + retryOf: jobID, + release, + }), + ]); + return "upload-2"; + }); + + await expect(registry.retryUpload(uploadJob())).resolves.toBe("upload-2"); + expect(harness.transport.retryUpload).toHaveBeenCalledWith("upload-1", "retry-correlation"); + expect(registry.snapshot().pending).toEqual([ + expect.objectContaining({ + acceptedJobID: "upload-2", + retryOf: "upload-1", + }), + ]); + registry.dispose(); + }); + + it("rebuilds immutable retained history from owner listing", async () => { + const harness = transportHarness(); + const retainedUpload = uploadJob(); + const listed: OwnerJobSnapshot[] = [retainedUpload, dupeJob()]; + harness.setJobs(listed); + const registry = new JobRegistryCoordinator("owner-a", harness.transport, new FakeClock()); + + await registry.refresh(); + expect(registry.snapshot().jobs.map((job) => job.jobID)).toEqual(["dupe-1", "upload-1"]); + retainedUpload.upload.failedTrackers[0] = "mutated"; + expect( + (registry.snapshot().jobs[1] as Extract).upload + .failedTrackers, + ).toEqual(["GRP"]); + }); +}); diff --git a/webui/src/jobRegistry/coordinator.ts b/webui/src/jobRegistry/coordinator.ts new file mode 100644 index 00000000..bc2eea78 --- /dev/null +++ b/webui/src/jobRegistry/coordinator.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { OwnerJobSnapshot, ReleaseRef } from "../types"; +import type { + DupeStartCommand, + JobRegistryClock, + JobRegistryState, + JobRegistryTransport, + PendingJobStart, +} from "./types"; + +const defaultClock: JobRegistryClock = { + setInterval: (callback, delayMs) => globalThis.setInterval(callback, delayMs), + clearInterval: (handle) => globalThis.clearInterval(handle as ReturnType), +}; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const compareJobs = (left: OwnerJobSnapshot, right: OwnerJobSnapshot) => { + const byTime = left.startedAt.localeCompare(right.startedAt); + return byTime !== 0 ? byTime : left.jobID.localeCompare(right.jobID); +}; + +const sameRelease = (left: ReleaseRef, right: ReleaseRef) => + left.SourcePath === right.SourcePath && left.Generation === right.Generation; + +const safeError = (error: unknown) => + error instanceof Error && error.message ? error.message : String(error); + +export class JobRegistryCoordinator { + private readonly listeners = new Set<() => void>(); + private readonly jobs = new Map(); + private readonly pending = new Map(); + private unsubscribe: (() => void) | null = null; + private pollHandle: unknown; + private disposed = false; + private bootstrapped = false; + private transientError = ""; + private refreshRevision = 0; + private currentState: JobRegistryState = { + jobs: [], + pending: [], + bootstrapped: false, + transientError: "", + }; + + constructor( + readonly ownerKey: string, + private readonly transport: JobRegistryTransport, + private readonly clock: JobRegistryClock = defaultClock, + private readonly pollIntervalMs = 1500, + private readonly newCorrelationID: () => string = () => + globalThis.crypto?.randomUUID?.() ?? + `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + ) {} + + start() { + if (this.disposed || this.unsubscribe) return; + this.unsubscribe = this.transport.subscribe((snapshot) => this.applySnapshot(snapshot)); + this.pollHandle = this.clock.setInterval(() => void this.refresh(), this.pollIntervalMs); + void this.refresh(); + } + + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + snapshot(): JobRegistryState { + return this.currentState; + } + + async refresh() { + const revision = ++this.refreshRevision; + try { + const listed = await this.transport.list(); + if (this.disposed || revision !== this.refreshRevision) return; + this.jobs.clear(); + listed.forEach((job) => this.jobs.set(job.jobID, clone(job))); + for (const [correlationID] of this.pending) { + if (listed.some((job) => job.correlationID === correlationID)) { + this.pending.delete(correlationID); + } + } + this.bootstrapped = true; + this.transientError = ""; + this.emit(); + } catch (error) { + if (this.disposed || revision !== this.refreshRevision) return; + this.bootstrapped = true; + this.transientError = safeError(error); + this.emit(); + } + } + + startDupe(command: DupeStartCommand) { + return this.startJob("duplicate_check", command.release, (correlationID) => + this.transport.startDupe(command, correlationID), + ); + } + + startUpload(release: ReleaseRef, token: string) { + return this.startJob("tracker_upload", release, (correlationID) => + this.transport.startUpload(token, correlationID), + ); + } + + retryUpload(job: OwnerJobSnapshot) { + return this.startJob( + "tracker_upload", + job.release, + (correlationID) => this.transport.retryUpload(job.jobID, correlationID), + job.jobID, + ); + } + + cancel(job: OwnerJobSnapshot) { + return job.kind === "duplicate_check" + ? this.transport.cancelDupe(job.jobID) + : this.transport.cancelUpload(job.jobID); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.refreshRevision++; + this.unsubscribe?.(); + this.unsubscribe = null; + if (this.pollHandle !== undefined) this.clock.clearInterval(this.pollHandle); + this.listeners.clear(); + this.jobs.clear(); + this.pending.clear(); + } + + private async startJob( + kind: OwnerJobSnapshot["kind"], + release: ReleaseRef, + start: (correlationID: string) => Promise, + retryOf?: string, + ) { + const correlationID = this.newCorrelationID(); + this.pending.set(correlationID, { kind, correlationID, release: clone(release), retryOf }); + this.emit(); + try { + const jobID = await start(correlationID); + if (this.disposed) return jobID; + if (this.pending.has(correlationID)) { + this.pending.set(correlationID, { + kind, + correlationID, + release: clone(release), + retryOf, + acceptedJobID: jobID, + }); + this.emit(); + } + void this.refresh(); + return jobID; + } catch (error) { + await this.refresh(); + const recovered = Array.from(this.jobs.values()).find( + (job) => job.correlationID === correlationID && sameRelease(job.release, release), + ); + this.pending.delete(correlationID); + this.emit(); + if (recovered) return recovered.jobID; + throw error; + } + } + + private applySnapshot(snapshot: OwnerJobSnapshot) { + if (this.disposed) return; + this.jobs.set(snapshot.jobID, clone(snapshot)); + this.pending.delete(snapshot.correlationID); + this.transientError = ""; + this.emit(); + } + + private emit() { + if (this.disposed) return; + this.currentState = { + jobs: clone(Array.from(this.jobs.values()).sort(compareJobs)), + pending: clone(Array.from(this.pending.values())), + bootstrapped: this.bootstrapped, + transientError: this.transientError, + }; + this.listeners.forEach((listener) => listener()); + } +} diff --git a/webui/src/jobRegistry/index.tsx b/webui/src/jobRegistry/index.tsx new file mode 100644 index 00000000..833bac66 --- /dev/null +++ b/webui/src/jobRegistry/index.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { OwnerJobSnapshot, ReleaseRef } from "../types"; +import { JobRegistryCoordinator } from "./coordinator"; +import { productionJobRegistryTransport } from "./production"; +import type { DupeStartCommand, JobRegistryState, JobRegistryTransport } from "./types"; + +const RegistryContext = createContext(null); + +export function JobRegistryProvider({ + ownerKey, + transport, + children, +}: Readonly<{ + ownerKey: string; + transport?: JobRegistryTransport; + children: ReactNode; +}>) { + const coordinator = useMemo( + () => new JobRegistryCoordinator(ownerKey, transport ?? productionJobRegistryTransport()), + [ownerKey, transport], + ); + useEffect(() => { + coordinator.start(); + return () => coordinator.dispose(); + }, [coordinator]); + return {children}; +} + +const useCoordinatorState = (): [JobRegistryCoordinator, JobRegistryState] => { + const coordinator = useContext(RegistryContext); + if (!coordinator) throw new Error("JobRegistryProvider is required"); + const state = useSyncExternalStore( + (listener) => coordinator.subscribe(listener), + () => coordinator.snapshot(), + () => coordinator.snapshot(), + ); + return [coordinator, state]; +}; + +const isSameRelease = (left: ReleaseRef, right: ReleaseRef) => + left.SourcePath === right.SourcePath && left.Generation === right.Generation; + +/** Current exact-generation duplicate/upload Jobs and narrow lifecycle commands. */ +export const useReleaseJobs = (release: ReleaseRef) => { + const [coordinator, state] = useCoordinatorState(); + const releaseJobs = state.jobs.filter((job) => isSameRelease(job.release, release)); + const dupeJobs = releaseJobs.filter( + (job): job is Extract => + job.kind === "duplicate_check", + ); + const uploadJobs = releaseJobs.filter( + (job): job is Extract => + job.kind === "tracker_upload", + ); + const dupe = dupeJobs[dupeJobs.length - 1]; + const upload = uploadJobs[uploadJobs.length - 1]; + return { + dupe, + upload, + pending: state.pending.filter((job) => isSameRelease(job.release, release)), + bootstrapped: state.bootstrapped, + transientError: state.transientError, + startDupe: (command: Omit) => + coordinator.startDupe({ ...command, release }), + startUpload: (token: string) => coordinator.startUpload(release, token), + retryUpload: (job: OwnerJobSnapshot) => coordinator.retryUpload(job), + cancel: (job: OwnerJobSnapshot) => coordinator.cancel(job), + } as const; +}; + +/** Immutable retained Job history for shell notifications. */ +export const useJobNotifications = () => { + const [, state] = useCoordinatorState(); + return { + jobs: state.jobs, + pending: state.pending, + bootstrapped: state.bootstrapped, + transientError: state.transientError, + } as const; +}; + +export type { JobRegistryTransport } from "./types"; diff --git a/webui/src/jobRegistry/production.ts b/webui/src/jobRegistry/production.ts new file mode 100644 index 00000000..f6df35b3 --- /dev/null +++ b/webui/src/jobRegistry/production.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { dupeClient, jobsClient, uploadClient } from "../api/app"; +import { subscribeWebEvent } from "../api/client"; +import type { OwnerJobSnapshot } from "../types"; +import type { JobRegistryTransport } from "./types"; + +export const productionJobRegistryTransport = (): JobRegistryTransport => ({ + list: jobsClient.list, + subscribe: (listener) => + subscribeWebEvent("jobs:update", (payload) => listener(payload as OwnerJobSnapshot)), + startDupe: (command, correlationID) => + dupeClient.start(command.release, [...command.trackers], correlationID), + startUpload: uploadClient.startReviewed, + retryUpload: uploadClient.retryFailed, + cancelDupe: dupeClient.cancel, + cancelUpload: uploadClient.cancel, +}); diff --git a/webui/src/jobRegistry/types.ts b/webui/src/jobRegistry/types.ts new file mode 100644 index 00000000..2d64b5db --- /dev/null +++ b/webui/src/jobRegistry/types.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { OwnerJobSnapshot, ReleaseRef } from "../types"; + +export type DupeStartCommand = Readonly<{ + release: ReleaseRef; + trackers: readonly string[]; +}>; + +export type PendingJobStart = Readonly<{ + kind: OwnerJobSnapshot["kind"]; + correlationID: string; + release: ReleaseRef; + retryOf?: string; + acceptedJobID?: string; +}>; + +export type JobRegistryState = Readonly<{ + jobs: readonly OwnerJobSnapshot[]; + pending: readonly PendingJobStart[]; + bootstrapped: boolean; + transientError: string; +}>; + +export type JobRegistryTransport = Readonly<{ + list: () => Promise; + subscribe: (listener: (snapshot: OwnerJobSnapshot) => void) => () => void; + startDupe: (command: DupeStartCommand, correlationID: string) => Promise; + startUpload: (token: string, correlationID: string) => Promise; + retryUpload: (jobID: string, correlationID: string) => Promise; + cancelDupe: (jobID: string) => Promise; + cancelUpload: (jobID: string) => Promise; +}>; + +export type JobRegistryClock = Readonly<{ + setInterval: (callback: () => void, delayMs: number) => unknown; + clearInterval: (handle: unknown) => void; +}>; diff --git a/gui/frontend/src/main.tsx b/webui/src/main.tsx similarity index 100% rename from gui/frontend/src/main.tsx rename to webui/src/main.tsx diff --git a/webui/src/pages/bluray_candidates/index.tsx b/webui/src/pages/bluray_candidates/index.tsx new file mode 100644 index 00000000..a6c5c461 --- /dev/null +++ b/webui/src/pages/bluray_candidates/index.tsx @@ -0,0 +1,210 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { InputFacet } from "../../releaseSession/types"; +import type { BlurayReleaseCandidate } from "../../types"; +import { handleExternalLinkClick } from "../../utils/externalLinks"; + +type Props = { + facet: InputFacet; + setLightboxImage: (url: string) => void; + setLightboxAlt: (alt: string) => void; +}; + +const scoreLabel = (candidate: BlurayReleaseCandidate) => `${candidate.Score.toFixed(1)}/100`; + +export default function BlurayCandidatesPage(props: Props) { + const { facet, setLightboxImage, setLightboxAlt } = props; + const selecting = facet.view.status === "running"; + const error = facet.view.error; + const bluray = facet.view.preview?.Bluray; + const candidates = bluray?.Candidates || []; + const selectedID = bluray?.SelectedReleaseID || ""; + + return ( +
+
+

Blu-ray.com

+

Release Candidates

+

Best accepted match loads by score; select another release here.

+
+ + {error ?

{error}

: null} + + {!bluray ? ( +
+

No Blu-ray.com lookup data available.

+
+ ) : ( + <> +
+
+
+

Best score

+

{bluray.BestScore.toFixed(1)}

+
+
+

Required score

+

{bluray.Threshold.toFixed(1)}

+
+
+

Auto-selected

+

{bluray.AutoSelected ? "Yes" : "No"}

+
+
+

Candidates

+

{candidates.length}

+
+
+ {bluray.SearchURL ? ( + + Open search + + ) : null} + {!selectedID && bluray.SelectionReason ? ( +

+ {bluray.SelectionReason} +

+ ) : null} +
+ + {candidates.length === 0 ? ( +
+

No release candidates found.

+
+ ) : ( +
+ {candidates.map((candidate) => { + const selected = candidate.ReleaseID === selectedID || candidate.Accepted; + const hasReleaseID = Boolean(candidate.ReleaseID); + return ( +
+
+
+

{candidate.Title || "-"}

+

+ {[candidate.MovieTitle, candidate.MovieYear].filter(Boolean).join(" ")} +

+
+ +
+ +
+
+

Score

+

{scoreLabel(candidate)}

+
+
+

Country

+

{candidate.Country || "-"}

+
+
+

Region

+

{candidate.Region || "-"}

+
+
+

Publisher

+

{candidate.Publisher || "-"}

+
+
+

Disc

+

{candidate.Specs?.Discs?.Format || "-"}

+
+
+ + {candidate.URL ? ( + + Open release + + ) : null} + +
+
+

Video

+

+ {[candidate.Specs?.Video?.Codec, candidate.Specs?.Video?.Resolution] + .filter(Boolean) + .join(" ") || "-"} +

+
+
+

Audio

+

+ {(candidate.Specs?.Audio || []).slice(0, 3).join("; ") || "-"} +

+
+
+

Subtitles

+

+ {(candidate.Specs?.Subtitles || []).slice(0, 6).join(", ") || "-"} +

+
+
+ + {candidate.MatchNotes?.length ? ( +
+

Score notes

+

+ {candidate.MatchNotes.slice(0, 5).join(" | ")} +

+
+ ) : null} + + {candidate.CoverImages?.length ? ( +
+ {candidate.CoverImages.map((image) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ )} + + )} +
+ ); +} diff --git a/webui/src/pages/description_builder/index.test.tsx b/webui/src/pages/description_builder/index.test.tsx new file mode 100644 index 00000000..0c50cad6 --- /dev/null +++ b/webui/src/pages/description_builder/index.test.tsx @@ -0,0 +1,67 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DescriptionsFacet } from "../../releaseSession/types"; +import DescriptionBuilderPage from "./index"; + +afterEach(cleanup); + +const facet = (): DescriptionsFacet => ({ + view: { + revision: 1, + status: "ready", + preview: { + SourcePath: "C:\\media\\Example", + ContentFailures: [], + Groups: [ + { + GroupKey: "unit3d", + Trackers: ["EXAMPLE"], + RawDescription: "raw", + RawDescriptionHTML: "

raw

", + Description: "raw", + DescriptionHTML: "

raw

", + HasOverride: false, + ImageHost: { + Status: "ready", + SelectedHost: "", + AllowedHosts: [], + Reuploaded: false, + Message: "", + }, + }, + ], + }, + rawByGroup: { unit3d: "raw" }, + renderedByGroup: { unit3d: "

raw

" }, + dirtyGroups: [], + staleReason: "", + notice: "", + error: "", + }, + load: vi.fn(async () => true), + edit: vi.fn(), + render: vi.fn(async () => true), + save: vi.fn(async () => true), + reset: vi.fn(async () => true), +}); + +describe("DescriptionBuilderPage", () => { + it("forwards edits and explicit save through the facet", () => { + const descriptions = facet(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Expand" })); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "changed" } }); + fireEvent.click(screen.getByRole("button", { name: "Save group" })); + expect(descriptions.edit).toHaveBeenCalledWith("unit3d", "changed"); + expect(descriptions.save).toHaveBeenCalledWith("unit3d"); + }); +}); diff --git a/webui/src/pages/description_builder/index.tsx b/webui/src/pages/description_builder/index.tsx new file mode 100644 index 00000000..36850143 --- /dev/null +++ b/webui/src/pages/description_builder/index.tsx @@ -0,0 +1,225 @@ +// Copyright (c) 2025-2026, Audionut and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +import { useState } from "react"; +import RenderedDescription from "../../components/RenderedDescription"; +import { TrackerIconImage } from "../../components/ui/tracker-icon"; +import type { TrackerIconCache } from "../../hooks/useTrackerIcons"; +import { trackerIconFor } from "../../hooks/useTrackerIcons"; +import type { DescriptionsFacet } from "../../releaseSession/types"; + +type Props = { + facet: DescriptionsFacet; + sourcePath: string; + useFavicons?: boolean; + faviconOnly?: boolean; + trackerIconSrcByName?: TrackerIconCache; +}; + +const groupLabel = (groupKey: string, trackers: string[]) => { + if (trackers.length > 0) return trackers.join(", "); + if (groupKey === "unit3d") return "Unit3D"; + return groupKey || "Description"; +}; + +export default function DescriptionBuilderPage(props: Props) { + const { + facet, + sourcePath, + useFavicons = true, + faviconOnly = false, + trackerIconSrcByName = {}, + } = props; + const [expandedGroups, setExpandedGroups] = useState>({}); + const { view } = facet; + const groups = view.preview?.Groups || []; + const builderLoading = view.status === "running"; + const builderError = view.error; + const builderSaved = view.notice; + + return ( +
+
+

Description Builder

+

Customize Description

+

+ Edit tracker-group raw descriptions here. Tracker-specific formatting is applied from this + builder. +

+
+ +
+
+

Source path

+

+ {sourcePath || "No path selected"} +

+
+ +
+ + {builderError ?

{builderError}

: null} + {builderSaved ?

{builderSaved}

: null} + + {builderLoading && groups.length === 0 ? ( +
+
+

Building Descriptions

+
+

+ Preparing tracker-group descriptions and image-host adjustments... +

+
+ ) : groups.length === 0 ? ( +
+

No tracker descriptions generated yet.

+
+ ) : ( + groups.map((group, i) => { + const groupKey = group.GroupKey; + const reactKey = groupKey || `default-${i}`; + const seededRaw = group.RawDescription || ""; + const raw = view.rawByGroup[groupKey] ?? seededRaw; + const seededRendered = group.RawDescriptionHTML || ""; + const renderedHTML = view.renderedByGroup[groupKey] ?? seededRendered; + const expanded = expandedGroups[groupKey] ?? false; + const trackers = (group.Trackers || []).map((tracker) => tracker.trim()).filter(Boolean); + const label = groupLabel(groupKey, trackers); + const hideTrackerNames = faviconOnly && useFavicons && trackers.length > 0; + const imageHostWarnings = group.ImageHost?.Warnings || []; + + return ( +
+
+
+

+ + {trackers.map((tracker) => ( + + ))} + {hideTrackerNames ? null : label} + +

+

+ {group.HasOverride + ? "Saved override active for this group." + : "Using generated raw description."} +

+ {group.ImageHost?.Reuploaded && group.ImageHost?.Message ? ( +

{group.ImageHost.Message}

+ ) : null} + {group.ImageHost?.Status === "warning" && group.ImageHost?.Message ? ( +

+ {group.ImageHost.Message} +

+ ) : null} + {imageHostWarnings.map((warning, index) => { + const host = String(warning.Host || "").trim(); + const message = String(warning.Message || "").trim(); + if (!host && !message) return null; + return ( +

+ {host ? `${host} failed` : "Image host warning"} + {message ? `: ${message}` : ""} +

+ ); + })} +
+ +
+ + {expanded ? ( + <> +
+ + + +
+ +
+
+

Raw Description

+

+ This final raw description is the upload source of truth for{" "} + {hideTrackerNames ? "this group" : label}. +

+
+